diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
index 173e878f8..aaa781cae 100644
--- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
+++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
@@ -851,6 +851,17 @@ public void SetupWizardStartup_WhenLoaded_SchedulesVersionCheckInsteadOfReadingS
Assert.That(setupWizardSource, Does.Not.Contain("\n TryShowOnVersionChange();"));
}
+ [Test]
+ public void MigrationWizardStartup_WhenLoaded_SchedulesTargetCheckInsteadOfPreviewingSynchronously()
+ {
+ // Tests that migration target detection does not block the synchronous Editor startup hook.
+ string migrationWizardSource = ReadProductionSource(
+ "Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs");
+
+ Assert.That(migrationWizardSource, Does.Contain("EditorApplication.delayCall += TryShowOnMigrationTargets;"));
+ Assert.That(migrationWizardSource, Does.Not.Contain("\n TryShowOnMigrationTargets();"));
+ }
+
[Test]
public void ProjectAsmdefs_WhenLoaded_DoNotReferenceRemovedSharedAssemblyGuid()
{
diff --git a/Assets/Tests/Editor/SetupWizardWindowTests.cs b/Assets/Tests/Editor/SetupWizardWindowTests.cs
index 68a5eaa98..b47a3b80a 100644
--- a/Assets/Tests/Editor/SetupWizardWindowTests.cs
+++ b/Assets/Tests/Editor/SetupWizardWindowTests.cs
@@ -65,7 +65,10 @@ public void ShouldAutoShowForVersion_ReturnsExpectedValue(
bool expected)
{
bool shouldAutoShow =
- SetupWizardWindow.ShouldAutoShowForVersion(currentVersion, lastSeenVersion, suppressAutoShow);
+ SetupWizardWindow.ShouldAutoShowForVersion(
+ currentVersion,
+ lastSeenVersion,
+ suppressAutoShow);
Assert.That(shouldAutoShow, Is.EqualTo(expected));
}
diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs
new file mode 100644
index 000000000..38650afdf
--- /dev/null
+++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs
@@ -0,0 +1,2375 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using NUnit.Framework;
+
+using Newtonsoft.Json.Linq;
+
+using io.github.hatayama.UnityCliLoop.Domain;
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies project-wide V3 third-party tool migration file behavior.
+ ///
+ public sealed class ThirdPartyToolMigrationFileServiceTests
+ {
+ [Test]
+ public void ThirdPartyToolMigrationPreview_WhenInputFilePathsMutate_KeepsSnapshot()
+ {
+ // Verifies that preview file paths cannot be changed by mutating the constructor input array.
+ string[] filePaths =
+ {
+ "Assets/VendorTools/HelloTool.cs"
+ };
+ ThirdPartyToolMigrationPreview preview = new(1, 1, filePaths);
+
+ filePaths[0] = "Assets/Changed.cs";
+ preview.FilePaths[0] = "Assets/ChangedAgain.cs";
+
+ Assert.That(preview.FilePaths, Is.EqualTo(new[] { "Assets/VendorTools/HelloTool.cs" }));
+ }
+
+ [Test]
+ public void ThirdPartyToolMigrationResult_WhenInputFilePathsMutate_KeepsSnapshot()
+ {
+ // Verifies that result file paths cannot be changed by mutating the constructor input array.
+ string[] filePaths =
+ {
+ "Assets/VendorTools/HelloTool.cs"
+ };
+ ThirdPartyToolMigrationResult result = new(1, 1, filePaths);
+
+ filePaths[0] = "Assets/Changed.cs";
+ result.FilePaths[0] = "Assets/ChangedAgain.cs";
+
+ Assert.That(result.FilePaths, Is.EqualTo(new[] { "Assets/VendorTools/HelloTool.cs" }));
+ }
+
+ [Test]
+ public void TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse()
+ {
+ // Verifies that migration scans skip unreadable assembly JSON files.
+ bool success = ThirdPartyToolMigrationFileService.TryReadJsonObjectForMigration(
+ "Assets/VendorTools/VendorTools.Editor.asmdef",
+ _ => throw new IOException("locked"),
+ out JObject jsonObject);
+
+ Assert.That(success, Is.False);
+ Assert.That(jsonObject, Is.Null);
+ }
+
+ [Test]
+ public void TryReadJsonObjectForMigration_WhenReadThrowsUnauthorizedAccessException_ReturnsFalse()
+ {
+ // Verifies that migration scans skip assembly JSON files blocked by file permissions.
+ bool success = ThirdPartyToolMigrationFileService.TryReadJsonObjectForMigration(
+ "Assets/VendorTools/VendorTools.Editor.asmdef",
+ _ => throw new UnauthorizedAccessException("denied"),
+ out JObject jsonObject);
+
+ Assert.That(success, Is.False);
+ Assert.That(jsonObject, Is.Null);
+ }
+
+ [Test]
+ public void ApplyMigration_WhenLegacyToolAssemblyExists_RewritesSourceAndAsmdef()
+ {
+ // Verifies that project-wide migration rewrites both custom tool source and its asmdef reference.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot);
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(preview.FileCount, Is.EqualTo(2));
+ Assert.That(preview.FilePaths.Contains(toolPath), Is.True);
+ Assert.That(preview.FilePaths.Contains(asmdefPath), Is.True);
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenLegacyToolAsmdefHasNoReferencesArray_AddsAsmdefReference()
+ {
+ // Verifies that valid minimal asmdefs compile after project-wide migration.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor""
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain(@"""references"": ["));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenManualRegistrationExists_AddsApplicationReference()
+ {
+ // Verifies that migrated manual registration source keeps access to the V3 registrar assembly.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "ManualToolRegistration.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+public static class ManualToolRegistration
+{
+ public static void Register(IUnityTool tool)
+ {
+ CustomToolManager.RegisterCustomTool(tool);
+ }
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.RegisterCustomTool"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenCurrentManualRegistrationExists_PreservesApplicationReference()
+ {
+ // Verifies that mixed assemblies keep current registrar dependencies while legacy tools migrate.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(registrationPath, @"using io.github.hatayama.UnityCliLoop.Application;
+
+public static class CurrentManualToolRegistration
+{
+ public static void Register(object tool)
+ {
+ UnityCliLoopToolRegistrar.RegisterCustomTool(tool);
+ }
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(registrationPath), Does.Contain("UnityCliLoopToolRegistrar"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenCurrentManualRegistrationExistsWithLegacyAsmdefName_AddsApplicationReference()
+ {
+ // Verifies that partially migrated registrar code receives the asmdef refs it already requires.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application;
+
+public static class CurrentManualToolRegistration
+{
+ public static void Register(object tool)
+ {
+ UnityCliLoopToolRegistrar.RegisterCustomTool(tool);
+ }
+}";
+ File.WriteAllText(registrationPath, registrationSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""uLoopMCP.Editor""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenCurrentManualRegistrationExistsWithLegacyAsmdefGuid_AddsContractReferences()
+ {
+ // Verifies that partially migrated GUID refs are expanded to the assemblies current registrar APIs expose.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+public static class CurrentManualToolRegistration
+{
+ public static void Register(IUnityCliLoopTool tool)
+ {
+ UnityCliLoopToolRegistrar.RegisterCustomTool(tool);
+ }
+}";
+ File.WriteAllText(registrationPath, registrationSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenCurrentToolContractsExistsWithLegacyAsmdefGuid_RewritesToToolContractsReference()
+ {
+ // Verifies that partially migrated tool implementations receive the ToolContracts asmdef reference.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string toolSource = @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}";
+ File.WriteAllText(toolPath, toolSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(toolPath), Is.EqualTo(toolSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenCurrentRegistrarExistsWithToolContractsAsmdefReference_AddsApplicationReference()
+ {
+ // Verifies that already-replaced asmdef refs still receive missing current assembly dependencies.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+public static class CurrentManualToolRegistration
+{
+ public static void Register(IUnityCliLoopTool tool)
+ {
+ UnityCliLoopToolRegistrar.RegisterCustomTool(tool);
+ }
+}";
+ File.WriteAllText(registrationPath, registrationSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:fc3fd32eddbee40e39c2d76dc184957b""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenLegacyRegistrarReturnIsUsedWithoutExplicitToolInfo_AddsDomainReference()
+ {
+ // Verifies that registrar return types add the Domain dependency even without explicit ToolInfo usage.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string registrationPath = Path.Combine(toolDirectory, "ToolCountLabel.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(registrationPath, @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolCountLabel
+{
+ public static string GetLabel()
+ {
+ return $""Tools: {CustomToolManager.GetRegisteredCustomTools().Length}"";
+ }
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(registrationPath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenDomainMetadataExistsWithoutManualRegistration_AddsDomainReference()
+ {
+ // Verifies that ToolInfo-only metadata helpers migrate their asmdef dependency.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(metadataPath, @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolMetadataProvider
+{
+ public static ToolInfo[] GetTools()
+ {
+ return new ToolInfo[0];
+ }
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(metadataPath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ToolInfo[]"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenLegacyDomainHelperExists_AddsDomainReference()
+ {
+ // Verifies that helpers moved to Domain migrate their source and asmdef dependency together.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string helperPath = Path.Combine(toolDirectory, "ToolHelper.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(helperPath, @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolHelper
+{
+ public static ServiceResult CreateResult()
+ {
+ return ServiceResult.SuccessResult(1);
+ }
+
+ public static ToolSettingsCatalogItem[] GetCatalog()
+ {
+ return new ToolSettingsCatalogItem[0];
+ }
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(helperPath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ServiceResult CreateResult"));
+ Assert.That(File.ReadAllText(helperPath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[] GetCatalog"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenCurrentDomainMetadataExistsWithLegacyAsmdefGuid_AddsDomainReference()
+ {
+ // Verifies that partially migrated metadata helpers receive the asmdef refs they already require.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string metadataSource = @"using io.github.hatayama.UnityCliLoop.Domain;
+
+public static class ToolMetadataProvider
+{
+ public static ToolInfo[] GetTools()
+ {
+ return new ToolInfo[0];
+ }
+}";
+ File.WriteAllText(metadataPath, metadataSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(metadataPath), Is.EqualTo(metadataSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesCurrentDomainGlobalUsingAndSplitMetadata_AddsDomainReference()
+ {
+ // Verifies that split V3 Domain metadata files receive their required asmdef references.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string globalUsingSource = "global using io.github.hatayama.UnityCliLoop.Domain;";
+ string metadataSource = @"public static class ToolMetadataProvider
+{
+ public static ToolInfo[] GetTools()
+ {
+ return new ToolInfo[0];
+ }
+}";
+ File.WriteAllText(globalUsingPath, globalUsingSource);
+ File.WriteAllText(metadataPath, metadataSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(globalUsingPath), Is.EqualTo(globalUsingSource));
+ Assert.That(File.ReadAllText(metadataPath), Is.EqualTo(metadataSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenUnrelatedAsmdefJsonIsMalformedAndNoAsmrefs_AppliesAsmdefRepair()
+ {
+ // Verifies that unrelated malformed asmdefs do not block applying repairable asmdef changes.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated");
+ Directory.CreateDirectory(unrelatedDirectory);
+ File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{");
+
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenUnrelatedAsmdefJsonIsMalformedAndAsmrefExists_AppliesAsmdefRepair()
+ {
+ // Verifies that malformed asmdefs do not block asmref-aware migration scans.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated");
+ Directory.CreateDirectory(unrelatedDirectory);
+ File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{");
+
+ string asmrefDirectory = Path.Combine(projectRoot, "Assets", "VendorToolParts");
+ Directory.CreateDirectory(asmrefDirectory);
+ File.WriteAllText(Path.Combine(asmrefDirectory, "VendorTools.Editor.asmref"), @"{
+ ""reference"": ""VendorTools.Editor""
+}");
+
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenUnrelatedAsmrefJsonIsMalformed_AppliesAsmdefRepair()
+ {
+ // Verifies that malformed asmrefs do not block migration scans for valid assemblies.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated");
+ Directory.CreateDirectory(unrelatedDirectory);
+ File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmref"), "{");
+
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitContractFiles()
+ {
+ // Verifies that schema files relying on global legacy usings migrate with their assembly.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string schemaPath = Path.Combine(toolDirectory, "HelloSchema.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(schemaPath, "public sealed class HelloSchema : BaseToolSchema {}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(schemaPath), Does.Contain("UnityCliLoopToolSchema"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_KeepsUnrelatedFiles()
+ {
+ // Verifies that assembly-level migration does not rename unrelated project types.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string unrelatedPath = Path.Combine(toolDirectory, "BaseToolSchema.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string unrelatedSource = "public sealed class BaseToolSchema {}";
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(unrelatedPath, unrelatedSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(unrelatedPath), Is.EqualTo(unrelatedSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesFileScopedLegacyUsing_KeepsUnrelatedBareTypes()
+ {
+ // Verifies that file-scoped imports do not grant legacy type context to sibling files.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string unrelatedPath = Path.Combine(toolDirectory, "UnrelatedMetadata.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string unrelatedSource =
+ "public sealed class UnrelatedMetadata { public BaseToolResponse Response; }";
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(unrelatedPath, unrelatedSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(unrelatedPath), Is.EqualTo(unrelatedSource));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesGenericSplitContractFiles()
+ {
+ // Verifies that collection-shaped legacy type references migrate with their assembly.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string listPath = Path.Combine(toolDirectory, "ToolList.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(
+ listPath,
+ "public sealed class ToolList { public System.Collections.Generic.List Tools; }");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(listPath), Does.Contain(
+ "System.Collections.Generic.List"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitDomainHelpers()
+ {
+ // Verifies that split Domain helper files receive source and asmdef migration through global usings.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string helperPath = Path.Combine(toolDirectory, "ToolHelper.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(
+ helperPath,
+ "public static class ToolHelper { public static ServiceResult Create() => null; }");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(helperPath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ServiceResult Create"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitReturnContractTypes()
+ {
+ // Verifies that helper return types relying on global legacy usings migrate with their assembly.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string responseFactoryPath = Path.Combine(toolDirectory, "ResponseFactory.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(
+ responseFactoryPath,
+ "public static class ResponseFactory { public static BaseToolResponse Create() => null; }");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(responseFactoryPath), Does.Contain("UnityCliLoopToolResponse Create"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitBareToolAttributes()
+ {
+ // Verifies that attribute-only files relying on global legacy usings migrate with their assembly.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string toolAttributePath = Path.Combine(toolDirectory, "HelloTool.Attribute.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(
+ toolAttributePath,
+ "[McpTool]\npublic sealed partial class HelloTool\n{\n}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(toolAttributePath), Does.Contain("[UnityCliLoopTool]"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyAlias_RewritesSplitAliasQualifiedFiles()
+ {
+ // Verifies that global namespace aliases provide legacy context to sibling files.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string toolAttributePath = Path.Combine(toolDirectory, "HelloTool.Attribute.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using Old = io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(
+ toolAttributePath,
+ "[Old.McpTool]\npublic sealed partial class HelloTool\n{\n private Old.IUnityTool tool;\n}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(toolAttributePath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(toolAttributePath), Does.Contain("Old.IUnityCliLoopTool"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyAlias_RewritesSplitAliasQualifiedContractTypes()
+ {
+ // Verifies that alias-qualified legacy contract types migrate in sibling files.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string responseFactoryPath = Path.Combine(toolDirectory, "ResponseFactory.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using Old = io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(
+ responseFactoryPath,
+ "public static class ResponseFactory { public static Old.BaseToolResponse Create() => null; }");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(responseFactoryPath), Does.Contain("Old.UnityCliLoopToolResponse Create"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyToolInfoAlias_RewritesSplitAliasConstructors()
+ {
+ // Verifies that global type aliases provide constructor migration context to sibling files.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(
+ globalUsingPath,
+ "global using LegacyToolInfo = io.github.hatayama.uLoopMCP.ToolInfo;");
+ File.WriteAllText(
+ metadataPath,
+ @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+public static class ToolMetadataProvider
+{
+ public static LegacyToolInfo Create(ToolParameterSchema schema)
+ {
+ return new LegacyToolInfo(""hello"", ""description"", schema);
+ }
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain(
+ "global using LegacyToolInfo = io.github.hatayama.UnityCliLoop.Domain.ToolInfo;"));
+ Assert.That(File.ReadAllText(metadataPath), Does.Contain(
+ "new io.github.hatayama.UnityCliLoop.Domain.ToolInfo(\"hello\", schema)"));
+ Assert.That(File.ReadAllText(metadataPath), Does.Not.Contain("\"description\", schema"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyToolInfoAlias_KeepsUnrelatedBareTypes()
+ {
+ // Verifies that ToolInfo type aliases do not grant full legacy namespace context to sibling files.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string unrelatedPath = Path.Combine(toolDirectory, "UnrelatedMetadata.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string unrelatedSource = "public sealed class UnrelatedMetadata { public BaseToolSchema Schema; }";
+ File.WriteAllText(
+ globalUsingPath,
+ "global using LegacyToolInfo = io.github.hatayama.uLoopMCP.ToolInfo;");
+ File.WriteAllText(unrelatedPath, unrelatedSource);
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain(
+ "global using LegacyToolInfo = io.github.hatayama.UnityCliLoop.Domain.ToolInfo;"));
+ Assert.That(File.ReadAllText(unrelatedPath), Is.EqualTo(unrelatedSource));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenLegacyToolExistsUnderAsmref_RewritesReferencedAsmdef()
+ {
+ // Verifies that asmref folders mark the referenced asmdef as the migrated assembly.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string asmdefDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ string asmrefDirectory = Path.Combine(projectRoot, "Assets", "VendorToolParts");
+ Directory.CreateDirectory(asmdefDirectory);
+ Directory.CreateDirectory(asmrefDirectory);
+ string toolPath = Path.Combine(asmrefDirectory, "HelloTool.cs");
+ string asmrefPath = Path.Combine(asmrefDirectory, "VendorTools.Editor.asmref");
+ string asmdefPath = Path.Combine(asmdefDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}");
+ File.WriteAllText(asmrefPath, @"{
+ ""reference"": ""VendorTools.Editor""
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenNestedAsmrefOverridesAncestorAsmdef_RewritesReferencedAsmdef()
+ {
+ // Verifies that nested asmref folders use their referenced assembly instead of an ancestor asmdef.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string ancestorDirectory = Path.Combine(projectRoot, "Assets", "OuterAssembly");
+ string asmrefDirectory = Path.Combine(ancestorDirectory, "VendorToolParts");
+ string targetDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(asmrefDirectory);
+ Directory.CreateDirectory(targetDirectory);
+ string ancestorAsmdefPath = Path.Combine(ancestorDirectory, "OuterAssembly.Editor.asmdef");
+ string toolPath = Path.Combine(asmrefDirectory, "HelloTool.cs");
+ string asmrefPath = Path.Combine(asmrefDirectory, "VendorTools.Editor.asmref");
+ string targetAsmdefPath = Path.Combine(targetDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(ancestorAsmdefPath, @"{
+ ""name"": ""OuterAssembly.Editor"",
+ ""references"": []
+}");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}");
+ File.WriteAllText(asmrefPath, @"{
+ ""reference"": ""VendorTools.Editor""
+}");
+ File.WriteAllText(targetAsmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(targetAsmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(ancestorAsmdefPath), Does.Not.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenNoAsmdefAssemblyUsesGlobalLegacyUsing_RewritesSplitContractFiles()
+ {
+ // Verifies that predefined assemblies get the same assembly-level migration as asmdef assemblies.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "Editor", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string schemaPath = Path.Combine(toolDirectory, "HelloSchema.cs");
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(schemaPath, "public sealed class HelloSchema : BaseToolSchema {}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(schemaPath), Does.Contain("UnityCliLoopToolSchema"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenNoAsmdefEditorAssemblyUsesGlobalLegacyUsing_KeepsRuntimeFiles()
+ {
+ // Verifies that predefined editor migration does not rewrite the separate runtime assembly.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string editorDirectory = Path.Combine(projectRoot, "Assets", "Editor", "VendorTools");
+ string runtimeDirectory = Path.Combine(projectRoot, "Assets", "Scripts");
+ Directory.CreateDirectory(editorDirectory);
+ Directory.CreateDirectory(runtimeDirectory);
+ string globalUsingPath = Path.Combine(editorDirectory, "GlobalUsings.cs");
+ string runtimePath = Path.Combine(runtimeDirectory, "BaseToolSchema.cs");
+ string runtimeSource = "public sealed class BaseToolSchema {}";
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(runtimePath, runtimeSource);
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(runtimePath), Is.EqualTo(runtimeSource));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenNoAsmdefFirstPassRuntimeUsesGlobalLegacyUsing_KeepsRegularRuntimeFiles()
+ {
+ // Verifies that Unity predefined firstpass runtime scripts do not migrate regular runtime siblings.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string firstPassDirectory = Path.Combine(projectRoot, "Assets", "Plugins");
+ string runtimeDirectory = Path.Combine(projectRoot, "Assets", "Scripts");
+ Directory.CreateDirectory(firstPassDirectory);
+ Directory.CreateDirectory(runtimeDirectory);
+ string globalUsingPath = Path.Combine(firstPassDirectory, "GlobalUsings.cs");
+ string runtimePath = Path.Combine(runtimeDirectory, "UnrelatedMetadata.cs");
+ string runtimeSource =
+ "public sealed class UnrelatedMetadata { public BaseToolResponse Response; }";
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(runtimePath, runtimeSource);
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(runtimePath), Is.EqualTo(runtimeSource));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenNoAsmdefFirstPassEditorUsesGlobalLegacyUsing_KeepsRegularEditorFiles()
+ {
+ // Verifies that Unity predefined firstpass editor scripts do not migrate regular editor siblings.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string firstPassEditorDirectory = Path.Combine(projectRoot, "Assets", "Plugins", "Editor");
+ string editorDirectory = Path.Combine(projectRoot, "Assets", "Editor");
+ Directory.CreateDirectory(firstPassEditorDirectory);
+ Directory.CreateDirectory(editorDirectory);
+ string globalUsingPath = Path.Combine(firstPassEditorDirectory, "GlobalUsings.cs");
+ string editorPath = Path.Combine(editorDirectory, "UnrelatedMetadata.cs");
+ string editorSource =
+ "public sealed class UnrelatedMetadata { public BaseToolResponse Response; }";
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(editorPath, editorSource);
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts"));
+ Assert.That(File.ReadAllText(editorPath), Is.EqualTo(editorSource));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsingAndSplitManualRegistration_AddsApplicationReference()
+ {
+ // Verifies that manual registration files relying on assembly-level legacy detection get required refs.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs");
+ string registrationPath = Path.Combine(toolDirectory, "ManualToolRegistration.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;");
+ File.WriteAllText(registrationPath, @"public static class ManualToolRegistration
+{
+ public static void Register(IUnityTool tool)
+ {
+ CustomToolManager.RegisterCustomTool(tool);
+ }
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(3));
+ Assert.That(File.ReadAllText(registrationPath), Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.RegisterCustomTool"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void PreviewMigration_WhenUnrelatedCustomToolManagerExists_KeepsAsmdef()
+ {
+ // Verifies that project-owned type names do not trigger migration dependencies by themselves.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string sourcePath = Path.Combine(toolDirectory, "CustomToolManager.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string source = "public sealed class CustomToolManager {}";
+ string asmdefSource = @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": []
+}";
+ File.WriteAllText(sourcePath, source);
+ File.WriteAllText(asmdefPath, asmdefSource);
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot);
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(preview.HasTargets, Is.False);
+ Assert.That(result.FileCount, Is.EqualTo(0));
+ Assert.That(File.ReadAllText(sourcePath), Is.EqualTo(source));
+ Assert.That(File.ReadAllText(asmdefPath), Is.EqualTo(asmdefSource));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenUserSidecarFilesExist_PreservesSidecarFiles()
+ {
+ // Verifies that project-wide source rewrites do not treat user sidecars as migration scratch files.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string tempSidecarPath = toolPath + ".tmp";
+ string backupSidecarPath = toolPath + ".bak";
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}");
+ File.WriteAllText(tempSidecarPath, "user temp sidecar");
+ File.WriteAllText(backupSidecarPath, "user backup sidecar");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(1));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(tempSidecarPath), Is.EqualTo("user temp sidecar"));
+ Assert.That(File.ReadAllText(backupSidecarPath), Is.EqualTo("user backup sidecar"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void PreviewMigration_WhenLegacyToolExistsUnderExcludedDirectory_IgnoresFile()
+ {
+ // Verifies that generated Unity folders are not scanned for third-party tools.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string generatedDirectory = Path.Combine(projectRoot, "Library");
+ Directory.CreateDirectory(generatedDirectory);
+ File.WriteAllText(
+ Path.Combine(generatedDirectory, "GeneratedTool.cs"),
+ "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class GeneratedTool {}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot);
+
+ Assert.That(preview.HasTargets, Is.False);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void PreviewMigration_WhenLegacyToolExistsOutsideAssets_IgnoresFile()
+ {
+ // Verifies that repository tooling outside Unity source roots is not migrated.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolsDirectory = Path.Combine(projectRoot, "tools");
+ Directory.CreateDirectory(toolsDirectory);
+ File.WriteAllText(
+ Path.Combine(toolsDirectory, "LegacyToolFixture.cs"),
+ "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class LegacyToolFixture {}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot);
+
+ Assert.That(preview.HasTargets, Is.False);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenLegacyToolExistsUnderAssetsPackagesDirectory_RewritesAssetsFiles()
+ {
+ // Verifies that only Unity's project-root Packages directory is excluded from migration scans.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "Packages", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(result.FileCount, Is.EqualTo(2));
+ Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool"));
+ Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void ApplyMigration_WhenLegacyToolExistsUnderPackagesDirectory_KeepsPackageFiles()
+ {
+ // Verifies that Package Manager and embedded package contents are not rewritten in place.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string packageDirectory = Path.Combine(
+ projectRoot,
+ "Packages",
+ "com.example.legacy-tool",
+ "Editor");
+ Directory.CreateDirectory(packageDirectory);
+ string toolPath = Path.Combine(packageDirectory, "PackageTool.cs");
+ string asmdefPath = Path.Combine(packageDirectory, "LegacyPackageTool.Editor.asmdef");
+ string toolSource = @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class PackageTool : AbstractUnityTool
+{
+}
+
+public sealed class PackageToolSchema : BaseToolSchema
+{
+}
+
+public sealed class PackageToolResponse : BaseToolResponse
+{
+}";
+ string asmdefSource = @"{
+ ""name"": ""LegacyPackageTool.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}";
+ File.WriteAllText(toolPath, toolSource);
+ File.WriteAllText(asmdefPath, asmdefSource);
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot);
+ ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot);
+
+ Assert.That(preview.HasTargets, Is.False);
+ Assert.That(result.FileCount, Is.EqualTo(0));
+ Assert.That(File.ReadAllText(toolPath), Is.EqualTo(toolSource));
+ Assert.That(File.ReadAllText(asmdefPath), Is.EqualTo(asmdefSource));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenLegacyToolExistsUnderAssets_ReturnsTrue()
+ {
+ // Verifies that startup detection can find V2 custom tools without building a migration preview.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "HelloTool.cs"),
+ "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.True);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenLegacyAsmdefNameExistsUnderAssets_ReturnsTrue()
+ {
+ // Verifies that startup detection catches old assembly names without relying on source files.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""uLoopMCP.Editor""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.True);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenCurrentToolContractsExistsWithLegacyAsmdefGuid_ReturnsTrue()
+ {
+ // Verifies that startup detection catches partially migrated tools that still need asmdef repair.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "CurrentHelloTool.cs"),
+ @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.True);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenUnrelatedAsmdefJsonIsMalformed_StillDetectsAsmdefRepair()
+ {
+ // Verifies that unrelated malformed asmdefs do not block startup detection for repairable tools.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated");
+ Directory.CreateDirectory(unrelatedDirectory);
+ File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{");
+
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "CurrentHelloTool.cs"),
+ @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.True);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenAssemblyUsesCurrentDomainGlobalUsingAndSplitMetadata_ReturnsTrue()
+ {
+ // Verifies that startup detection treats current Domain global usings as assembly-scoped.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "GlobalUsings.cs"),
+ "global using io.github.hatayama.UnityCliLoop.Domain;");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "ToolMetadataProvider.cs"),
+ @"public static class ToolMetadataProvider
+{
+ public static ToolInfo[] GetTools()
+ {
+ return new ToolInfo[0];
+ }
+}");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.True);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenCurrentApplicationGuidReferenceExists_ReturnsFalse()
+ {
+ // Verifies that current V3 Application references do not trigger the startup migration prompt.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs"),
+ @"using io.github.hatayama.UnityCliLoop.Application;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+public static class CurrentManualToolRegistration
+{
+ public static void Register(IUnityCliLoopTool tool)
+ {
+ UnityCliLoopToolRegistrar.RegisterCustomTool(tool);
+ }
+}");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d"",
+ ""GUID:fc3fd32eddbee40e39c2d76dc184957b""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.False);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenCurrentRegistrarReturnMissingDomainReference_ReturnsTrue()
+ {
+ // Verifies that startup detection catches registrar return calls that still need Domain asmdef refs.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "ToolCountLabel.cs"),
+ @"using io.github.hatayama.UnityCliLoop.Application;
+
+public static class ToolCountLabel
+{
+ public static string GetLabel()
+ {
+ return $""Tools: {UnityCliLoopToolRegistrar.GetRegisteredCustomTools().Length}"";
+ }
+}");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d"",
+ ""GUID:fc3fd32eddbee40e39c2d76dc184957b""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.True);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task HasMigrationTargetsAsync_WhenLegacyToolExistsUnderPackagesDirectory_ReturnsFalse()
+ {
+ // Verifies that startup detection keeps Package Manager contents outside migration scope.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string packageDirectory = Path.Combine(
+ projectRoot,
+ "Packages",
+ "com.example.legacy-tool",
+ "Editor");
+ Directory.CreateDirectory(packageDirectory);
+ File.WriteAllText(
+ Path.Combine(packageDirectory, "PackageTool.cs"),
+ "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class PackageTool {}");
+
+ ThirdPartyToolMigrationFileService service = new();
+
+ bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None);
+
+ Assert.That(hasTargets, Is.False);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void PreviewMigration_WhenCacheIsInvalidated_RefreshesChangedProject()
+ {
+ // Verifies that repeated setup wizard previews can reuse scans and refresh after invalidation.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationPreview firstPreview = service.PreviewMigration(projectRoot);
+ File.WriteAllText(toolPath, "public sealed class HelloTool {}");
+ ThirdPartyToolMigrationPreview cachedPreview = service.PreviewMigration(projectRoot);
+ service.InvalidatePreviewCache();
+ ThirdPartyToolMigrationPreview refreshedPreview = service.PreviewMigration(projectRoot);
+
+ Assert.That(firstPreview.HasTargets, Is.True);
+ Assert.That(cachedPreview.FileCount, Is.EqualTo(firstPreview.FileCount));
+ Assert.That(refreshedPreview.HasTargets, Is.False);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task PreviewMigrationAsync_WhenProjectChangesAfterCachedPreview_RefreshesCurrentProject()
+ {
+ // Verifies that migration wizard Refresh reads the current files instead of reusing a stale preview.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ Progress progress = new();
+ ThirdPartyToolMigrationPreview firstPreview =
+ await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None);
+ File.WriteAllText(toolPath, "public sealed class HelloTool {}");
+
+ ThirdPartyToolMigrationPreview refreshedPreview =
+ await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None);
+
+ Assert.That(firstPreview.HasTargets, Is.True);
+ Assert.That(refreshedPreview.HasTargets, Is.False);
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task PreviewMigrationAsync_WhenUnrelatedAsmdefJsonIsMalformedAndNoAsmrefs_PreviewsAsmdefRepair()
+ {
+ // Verifies that unrelated malformed asmdefs do not block previewing repairable asmdef changes.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated");
+ Directory.CreateDirectory(unrelatedDirectory);
+ File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{");
+
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "CurrentHelloTool.cs"),
+ @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ Progress progress = new();
+
+ ThirdPartyToolMigrationPreview preview =
+ await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None);
+
+ Assert.That(preview.HasTargets, Is.True);
+ Assert.That(preview.FileCount, Is.EqualTo(1));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task PreviewMigrationAsync_WhenUnrelatedAsmrefJsonIsMalformed_PreviewsAsmdefRepair()
+ {
+ // Verifies that malformed asmrefs do not block async preview scans for repairable assemblies.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated");
+ Directory.CreateDirectory(unrelatedDirectory);
+ File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmref"), "{");
+
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "CurrentHelloTool.cs"),
+ @"using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+[UnityCliLoopTool]
+public sealed class CurrentHelloTool : UnityCliLoopTool
+{
+}
+
+public sealed class HelloSchema : UnityCliLoopToolSchema
+{
+}
+
+public sealed class HelloResponse : UnityCliLoopToolResponse
+{
+}");
+ File.WriteAllText(
+ Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"),
+ @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ ThirdPartyToolMigrationFileService service = new();
+ Progress progress = new();
+
+ ThirdPartyToolMigrationPreview preview =
+ await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None);
+
+ Assert.That(preview.HasTargets, Is.True);
+ Assert.That(preview.FileCount, Is.EqualTo(1));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void PreviewMigration_WhenCurrentApplicationGuidReferenceExists_KeepsAsmdef()
+ {
+ // Verifies that V3 Application references are not reported as legacy asmdef migration.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+public static class CurrentManualToolRegistration
+{
+ public static void Register(IUnityCliLoopTool tool)
+ {
+ UnityCliLoopToolRegistrar.RegisterCustomTool(tool);
+ }
+}";
+ string asmdefSource = @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d"",
+ ""GUID:fc3fd32eddbee40e39c2d76dc184957b""
+ ]
+}";
+ File.WriteAllText(registrationPath, registrationSource);
+ File.WriteAllText(asmdefPath, asmdefSource);
+
+ ThirdPartyToolMigrationFileService service = new();
+ ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot);
+
+ Assert.That(preview.HasTargets, Is.False);
+ Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource));
+ Assert.That(File.ReadAllText(asmdefPath), Is.EqualTo(asmdefSource));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task PreviewMigrationAsync_WhenProjectContainsManyFiles_ReportsIncrementalProgress()
+ {
+ // Verifies that setup wizard previews report progress before the full scan finishes.
+ string projectRoot = CreateProjectRoot();
+ try
+ {
+ string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools");
+ Directory.CreateDirectory(toolDirectory);
+ for (int i = 0; i < 48; i++)
+ {
+ File.WriteAllText(
+ Path.Combine(toolDirectory, $"Plain{i}.cs"),
+ $"public sealed class Plain{i} {{}}");
+ }
+
+ string toolPath = Path.Combine(toolDirectory, "HelloTool.cs");
+ string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef");
+ File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP;
+
+[McpTool]
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+}
+
+public sealed class HelloResponse : BaseToolResponse
+{
+}");
+ File.WriteAllText(asmdefPath, @"{
+ ""name"": ""VendorTools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}");
+
+ List reports = new();
+ RecordingMigrationProgress progress = new(reports);
+ ThirdPartyToolMigrationFileService service = new();
+
+ ThirdPartyToolMigrationPreview preview =
+ await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None);
+
+ Assert.That(preview.HasTargets, Is.True);
+ Assert.That(reports.Count, Is.GreaterThan(1));
+ Assert.That(
+ reports.Any(report =>
+ report.TotalItemCount > 0 &&
+ report.ProcessedItemCount > 0 &&
+ report.ProcessedItemCount < report.TotalItemCount),
+ Is.True);
+ ThirdPartyToolMigrationProgress lastReport = reports[reports.Count - 1];
+ Assert.That(lastReport.ProcessedItemCount, Is.EqualTo(lastReport.TotalItemCount));
+ }
+ finally
+ {
+ Directory.Delete(projectRoot, recursive: true);
+ }
+ }
+
+ private static string CreateProjectRoot()
+ {
+ string projectRoot = Path.Combine(
+ Path.GetTempPath(),
+ "UnityCliLoopTests",
+ "ThirdPartyToolMigration",
+ Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectRoot);
+ Directory.CreateDirectory(Path.Combine(projectRoot, "ProjectSettings"));
+ Directory.CreateDirectory(Path.Combine(projectRoot, "Assets"));
+ return projectRoot;
+ }
+
+ private sealed class RecordingMigrationProgress : IProgress
+ {
+ private readonly List _reports;
+
+ public RecordingMigrationProgress(List reports)
+ {
+ Assert.That(reports, Is.Not.Null);
+
+ _reports = reports;
+ }
+
+ public void Report(ThirdPartyToolMigrationProgress value)
+ {
+ _reports.Add(value);
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta
new file mode 100644
index 000000000..6579d5119
--- /dev/null
+++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6a4d3c9261bd7430ea2876e1cc2881dd
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs
new file mode 100644
index 000000000..812c905e4
--- /dev/null
+++ b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs
@@ -0,0 +1,1494 @@
+using System.Linq;
+
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies V3 third-party tool migration rewrite rules.
+ ///
+ public sealed class ThirdPartyToolMigrationRulesTests
+ {
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolApiIsUsed_RewritesToV3Contracts()
+ {
+ // Verifies that V2 custom tool source is rewritten to the V3 public contract names.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+namespace Samples
+{
+ [McpTool(Description = ""hello"")]
+ public sealed class HelloTool : AbstractUnityTool
+ {
+ }
+
+ public sealed class HelloSchema : BaseToolSchema
+ {
+ }
+
+ public sealed class HelloResponse : BaseToolResponse
+ {
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("using io.github.hatayama.UnityCliLoop.ToolContracts;"));
+ Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopTool"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopToolSchema"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopToolResponse"));
+ Assert.That(result.Content, Does.Not.Contain("uLoopMCP"));
+ Assert.That(result.ReplacementCount, Is.EqualTo(5));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenNoLegacyToolApiIsUsed_KeepsContent()
+ {
+ // Verifies that unrelated C# files are not rewritten.
+ string source = "public sealed class PlainClass {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ Assert.That(result.ReplacementCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeSuffixHasArguments_DropsUnsupportedArguments()
+ {
+ // Verifies that old attribute suffix syntax does not keep removed V3 attribute arguments.
+ string source =
+ "using io.github.hatayama.uLoopMCP;\n" +
+ "[McpToolAttribute(Description = \"hello\")] public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]"));
+ Assert.That(result.Content, Does.Not.Contain("Description"));
+ Assert.That(result.Content, Does.Not.Contain("UnityCliLoopToolAttribute("));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeHasSupportedArguments_PreservesSupportedArguments()
+ {
+ // Verifies that migration drops removed metadata without changing supported tool visibility metadata.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "[McpTool(Description = \"hello\", DisplayDevelopmentOnly = true)] public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("[UnityCliLoopTool(DisplayDevelopmentOnly = true)]"));
+ Assert.That(result.Content, Does.Not.Contain("Description"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeDescriptionIsRawStringWithComma_DropsDescriptionArgument()
+ {
+ // Verifies that commas inside raw string literals do not split legacy attribute arguments.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "[McpTool(Description = \"\"\"\"say \"hi\", world\"\"\"\", DisplayDevelopmentOnly = true)] " +
+ "public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("[UnityCliLoopTool(DisplayDevelopmentOnly = true)]"));
+ Assert.That(result.Content, Does.Not.Contain("Description"));
+ Assert.That(result.Content, Does.Not.Contain("\"\"\"\"say \"hi\", world\"\"\"\""));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeHasSecurityArgument_RewritesSecurityArgument()
+ {
+ // Verifies that supported security metadata keeps compiling after the enum rename.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "[McpTool(RequiredSecuritySetting = SecuritySettings.None)] public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "[UnityCliLoopTool(RequiredSecuritySetting = UnityCliLoopSecuritySetting.None)]"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeUsesSecurityAlias_KeepsAliasIdentifier()
+ {
+ // Verifies that aliases containing the old enum name are not rewritten as partial identifiers.
+ string source = @"using LegacySecuritySettings = io.github.hatayama.uLoopMCP.SecuritySettings;
+using io.github.hatayama.uLoopMCP;
+
+[McpTool(RequiredSecuritySetting = LegacySecuritySettings.None)]
+public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "using LegacySecuritySettings = io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopSecuritySetting;"));
+ Assert.That(result.Content, Does.Contain(
+ "[UnityCliLoopTool(RequiredSecuritySetting = LegacySecuritySettings.None)]"));
+ Assert.That(result.Content, Does.Not.Contain("LegacyUnityCliLoopSecuritySetting"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeSharesAttributeList_RewritesOnlyLegacyToolEntry()
+ {
+ // Verifies that valid C# attribute lists migrate the tool attribute without dropping sibling attributes.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "[McpTool(Description = \"hello\"), System.Obsolete] public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("[UnityCliLoopTool, System.Obsolete]"));
+ Assert.That(result.Content, Does.Not.Contain("McpTool"));
+ Assert.That(result.Content, Does.Not.Contain("Description"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolDescriptionContainsBracket_RewritesAttribute()
+ {
+ // Verifies that description text cannot terminate the attribute-list scan early.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "[McpTool(Description = \"Use [foo]\")] public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]"));
+ Assert.That(result.Content, Does.Not.Contain("McpTool"));
+ Assert.That(result.Content, Does.Not.Contain("Description"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeIsQualified_RewritesQualifiedAttribute()
+ {
+ // Verifies that tools without a namespace import still receive a compilable V3 attribute.
+ string source =
+ "[io.github.hatayama.uLoopMCP.McpToolAttribute(Description = \"hello\")] public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "[io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool]"));
+ Assert.That(result.Content, Does.Not.Contain("McpTool"));
+ Assert.That(result.Content, Does.Not.Contain("Description"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeIsGlobalQualified_RewritesGlobalQualifiedAttribute()
+ {
+ // Verifies that global-qualified V2 attributes do not bypass argument cleanup.
+ string source =
+ "[global::io.github.hatayama.uLoopMCP.McpTool(Description = \"hello\")] public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "[io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool]"));
+ Assert.That(result.Content, Does.Not.Contain("McpTool"));
+ Assert.That(result.Content, Does.Not.Contain("Description"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolAttributeIsAliasQualified_RewritesAliasQualifiedAttribute()
+ {
+ // Verifies that namespace alias attribute shorthand migrates to a resolvable V3 attribute.
+ string source = @"using Old = io.github.hatayama.uLoopMCP;
+
+[Old.McpTool(DisplayDevelopmentOnly = true)]
+public sealed class HelloTool {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "[io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool(DisplayDevelopmentOnly = true)]"));
+ Assert.That(result.Content, Does.Not.Contain("Old.McpTool"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyPublicHelpersAreUsed_RewritesHelperTypes()
+ {
+ // Verifies that migrated custom tools keep compiling when they override helper-driven schema behavior.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public sealed class HelloTool
+{
+ public ToolParameterSchema ParameterSchema => ToolParameterSchemaGenerator.FromDto();
+
+ private void Fail()
+ {
+ throw new ParameterValidationException(""bad"");
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("UnityCliLoopToolParameterSchemaGenerator"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopToolParameterValidationException"));
+ Assert.That(result.Content, Does.Not.Match(@"\bToolParameterSchemaGenerator\b"));
+ Assert.That(result.Content, Does.Not.Match(@"\bParameterValidationException\b"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyMcpConstantsAreUsed_RewritesConstantsType()
+ {
+ // Verifies that legacy public constants references resolve to the V3 constants type.
+ string source =
+ "using io.github.hatayama.uLoopMCP;\n" +
+ "\n" +
+ "public static class ToolConstants\n" +
+ "{\n" +
+ " public const string Name = McpConstants.PROJECT_NAME;\n" +
+ " public static string QualifiedName => io.github.hatayama.uLoopMCP.McpConstants.PROJECT_NAME;\n" +
+ "}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("UnityCliLoopConstants.PROJECT_NAME"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopConstants.PROJECT_NAME"));
+ Assert.That(result.Content, Does.Not.Contain("McpConstants"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyRegistrarMetadataIsUsed_RewritesDomainMetadataType()
+ {
+ // Verifies that explicit registrar metadata declarations keep compiling after namespace migration.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public static class ManualToolRegistration
+{
+ public static ToolInfo[] GetTools()
+ {
+ return CustomToolManager.GetRegisteredCustomTools();
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("io.github.hatayama.UnityCliLoop.Domain.ToolInfo[]"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyDomainHelpersAreUsed_RewritesDomainHelperTypes()
+ {
+ // Verifies that legacy helpers moved to Domain keep compiling after namespace migration.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolHelper
+{
+ public static ServiceResult CreateResult()
+ {
+ return ServiceResult.SuccessResult(1);
+ }
+
+ public static ToolSettingsCatalogItem[] GetCatalog()
+ {
+ return new ToolSettingsCatalogItem[0];
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ServiceResult CreateResult"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ServiceResult.SuccessResult"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[] GetCatalog"));
+ Assert.That(result.Content, Does.Contain(
+ "new io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[0]"));
+ Assert.That(result.Content, Does.Not.Contain("ToolContracts.ServiceResult"));
+ Assert.That(result.Content, Does.Not.Contain("ToolContracts.ToolSettingsCatalogItem"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyToolSettingsCatalogItemConstructorHasDescription_RemovesDescriptionArgument()
+ {
+ // Verifies that old settings catalog metadata keeps compiling after the V3 constructor signature change.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolSettingsCatalogProvider
+{
+ public static ToolSettingsCatalogItem Create(bool displayDevelopmentOnly, bool isThirdParty)
+ {
+ return new ToolSettingsCatalogItem(""hello"", ""description"", displayDevelopmentOnly, isThirdParty);
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "new io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem(\"hello\", displayDevelopmentOnly, isThirdParty)"));
+ Assert.That(result.Content, Does.Not.Contain("\"description\", displayDevelopmentOnly"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyDomainHelpersUseNamespaceAlias_RewritesDomainHelperTypes()
+ {
+ // Verifies that namespace aliases targeting V2 helpers do not survive as ToolContracts references.
+ string source = @"using Old = io.github.hatayama.uLoopMCP;
+
+public static class ToolHelper
+{
+ public static Old.ServiceResult CreateResult()
+ {
+ return Old.ServiceResult.SuccessResult(1);
+ }
+
+ public static Old.ToolSettingsCatalogItem[] GetCatalog()
+ {
+ return new Old.ToolSettingsCatalogItem[0];
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ServiceResult CreateResult"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ServiceResult.SuccessResult"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[] GetCatalog"));
+ Assert.That(result.Content, Does.Not.Contain("Old.ServiceResult"));
+ Assert.That(result.Content, Does.Not.Contain("Old.ToolSettingsCatalogItem"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideInterpolatedString_RewritesInterpolationCode()
+ {
+ // Verifies that code inside interpolation holes migrates while literal text stays inert.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolCountLabel
+{
+ public static string GetLabel()
+ {
+ return $""Tools: {CustomToolManager.GetRegisteredCustomTools().Length}"";
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools"));
+ Assert.That(result.Content, Does.Not.Contain("{CustomToolManager"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideInterpolatedVerbatimString_RewritesInterpolationCode()
+ {
+ // Verifies that verbatim interpolation holes are treated as code.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolCountLabel
+{
+ public static string GetLabel()
+ {
+ return $@""Tools: {CustomToolManager.GetRegisteredCustomTools().Length}"";
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools"));
+ Assert.That(result.Content, Does.Not.Contain("{CustomToolManager"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideInterpolatedRawString_RewritesInterpolationCode()
+ {
+ // Verifies that raw interpolation holes are treated as code.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "\n" +
+ "public static class ToolCountLabel\n" +
+ "{\n" +
+ " public static string GetLabel()\n" +
+ " {\n" +
+ " return $\"\"\"Tools: {CustomToolManager.GetRegisteredCustomTools().Length}\"\"\";\n" +
+ " }\n" +
+ "}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools"));
+ Assert.That(result.Content, Does.Not.Contain("{CustomToolManager"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideMultiDollarRawString_RewritesInterpolationCode()
+ {
+ // Verifies that literal braces stay inert when multiple dollar signs are used.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "\n" +
+ "public static class ToolCountLabel\n" +
+ "{\n" +
+ " public static string GetLabel()\n" +
+ " {\n" +
+ " return $$\"\"\"Literal { braces } tools: {{CustomToolManager.GetRegisteredCustomTools().Length}}\"\"\";\n" +
+ " }\n" +
+ "}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("Literal { braces }"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools"));
+ Assert.That(result.Content, Does.Not.Contain("{{CustomToolManager"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyTextIsUsedInsideNestedInterpolatedStringLiteral_KeepsNestedLiteral()
+ {
+ // Verifies that string literals inside interpolation holes do not get rewritten as executable code.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "\n" +
+ "public static class ToolLabel\n" +
+ "{\n" +
+ " public static string GetLabel()\n" +
+ " {\n" +
+ " return $\"{Log(\"CustomToolManager\")}\";\n" +
+ " }\n" +
+ "\n" +
+ " private static string Log(string value)\n" +
+ " {\n" +
+ " return value;\n" +
+ " }\n" +
+ "}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("Log(\"CustomToolManager\")"));
+ Assert.That(result.Content, Does.Not.Contain("Log(\"io.github.hatayama.UnityCliLoop.Application"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyDomainMetadataIsUsedWithoutRegistrar_RewritesDomainMetadataType()
+ {
+ // Verifies that metadata helpers split away from registration code keep compiling after migration.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public static class ToolMetadataProvider
+{
+ public static ToolInfo[] GetTools()
+ {
+ return new ToolInfo[0];
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("io.github.hatayama.UnityCliLoop.Domain.ToolInfo[]"));
+ Assert.That(result.Content, Does.Not.Match(@"(?
+{
+ private Other.BaseToolResponse response;
+ private MyGame.SecuritySettings securitySettings;
+}
+
+public sealed class HelloSchema : BaseToolSchema {}
+
+public sealed class HelloResponse : BaseToolResponse {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("Other.BaseToolResponse"));
+ Assert.That(result.Content, Does.Contain("MyGame.SecuritySettings"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopTool"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopToolSchema"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopToolResponse"));
+ Assert.That(result.Content, Does.Not.Contain("Other.UnityCliLoopToolResponse"));
+ Assert.That(result.Content, Does.Not.Contain("MyGame.UnityCliLoopSecuritySetting"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyFileHasUnrelatedToolInfoProperty_KeepsIdentifier()
+ {
+ // Verifies that metadata type migration does not rewrite member names.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public sealed class HelloTool : AbstractUnityTool
+{
+ public string ToolInfo { get; }
+}
+
+public sealed class HelloSchema : BaseToolSchema {}
+
+public sealed class HelloResponse : BaseToolResponse {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("public string ToolInfo { get; }"));
+ Assert.That(result.Content, Does.Contain("UnityCliLoopTool"));
+ Assert.That(result.Content, Does.Not.Contain("public string io.github.hatayama.UnityCliLoop.Domain.ToolInfo"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyFileHasMemberNameMatchingLegacyContractType_KeepsIdentifier()
+ {
+ // Verifies that contract type migration does not rewrite member names.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public sealed class HelloTool : AbstractUnityTool
+{
+}
+
+public sealed class HelloSchema : BaseToolSchema
+{
+ public SecuritySettings SecuritySettings { get; set; }
+}
+
+public sealed class HelloResponse : BaseToolResponse {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "public UnityCliLoopSecuritySetting SecuritySettings { get; set; }"));
+ Assert.That(result.Content, Does.Not.Contain(
+ "UnityCliLoopSecuritySetting UnityCliLoopSecuritySetting"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyContractTypeIsFullyQualified_RewritesFullyQualifiedType()
+ {
+ // Verifies that qualified legacy namespace usage still migrates after qualified unrelated types are ignored.
+ string source = @"public sealed class HelloTool :
+ io.github.hatayama.uLoopMCP.AbstractUnityTool
+{
+}
+
+public sealed class HelloResponse : io.github.hatayama.uLoopMCP.BaseToolResponse {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool"));
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopToolResponse"));
+ Assert.That(result.Content, Does.Not.Contain("uLoopMCP"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyApiExistsOnlyInComment_KeepsContent()
+ {
+ // Verifies that migration does not rewrite inert documentation comments inside C# files.
+ string source = "// AbstractUnityTool should not be rewritten here";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyApiExistsOnlyInStringLiteral_KeepsContent()
+ {
+ // Verifies that migration does not rewrite test fixture strings or examples inside C# files.
+ string source = "public const string Example = \"IUnityTool\";";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenGenericLegacyNameExistsWithoutLegacyMarker_KeepsContent()
+ {
+ // Verifies that unrelated project types are not migrated just because their names resemble old API names.
+ string source = "public sealed class CustomToolManager { public SecuritySettings Settings; }";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyFileDeclaresCustomToolManagerIdentifier_KeepsIdentifier()
+ {
+ // Verifies that registrar migration does not rewrite unrelated declaration identifiers.
+ string source = @"using io.github.hatayama.uLoopMCP;
+
+public sealed class CustomToolManager
+{
+ public void CustomToolManager()
+ {
+ }
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("public sealed class CustomToolManager"));
+ Assert.That(result.Content, Does.Contain("public void CustomToolManager()"));
+ Assert.That(result.Content, Does.Not.Contain(
+ "public sealed class io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar"));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenBareMcpToolHasNoLegacyMarker_KeepsContent()
+ {
+ // Verifies that unrelated attribute types with the same short name do not trigger migration.
+ string source = @"using Some.Other.Mcp;
+
+[McpTool]
+public sealed class OtherTool
+{
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenUnqualifiedLegacyLikeBaseTypeHasNoLegacyMarker_KeepsContent()
+ {
+ // Verifies that unrelated base types with the same short name do not trigger migration.
+ string source = "public sealed class MyResponse : BaseToolResponse {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyNamespacePrefixExists_KeepsContent()
+ {
+ // Verifies that namespace matching does not treat prefixes as the V2 namespace.
+ string source = "using io.github.hatayama.uLoopMCPExtensions;";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateCSharpSourceForLegacyAssembly_WhenFileReliesOnGlobalUsing_RewritesContractTypes()
+ {
+ // Verifies that files split away from a legacy global using still migrate inside the same assembly.
+ string source = "public sealed class HelloSchema : BaseToolSchema {}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly(
+ source,
+ hasLegacyAssemblySource: true,
+ legacyAssemblyAliases: System.Array.Empty(),
+ legacyAssemblyToolInfoAliases: System.Array.Empty());
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("UnityCliLoopToolSchema"));
+ }
+
+ [Test]
+ public void MigrateCSharpSourceForLegacyAssembly_WhenFileReliesOnGlobalUsing_RewritesDomainHelpers()
+ {
+ // Verifies that split helper files relying on a legacy global using migrate to Domain types.
+ string source =
+ "public static class ToolHelper { public static ServiceResult Create() => null; }";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly(
+ source,
+ hasLegacyAssemblySource: true,
+ legacyAssemblyAliases: System.Array.Empty(),
+ legacyAssemblyToolInfoAliases: System.Array.Empty());
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(
+ "io.github.hatayama.UnityCliLoop.Domain.ServiceResult Create"));
+ }
+
+ [Test]
+ public void ContainsLegacyAssemblyScopedApi_WhenGenericLegacyTypesAreUsed_ReturnsTrue()
+ {
+ // Verifies that split files using collection-shaped legacy types are migrated with their assembly.
+ string source = "public sealed class ToolList { public System.Collections.Generic.List Tools; }";
+
+ bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi(
+ source,
+ System.Array.Empty());
+
+ Assert.That(containsLegacyApi, Is.True);
+ }
+
+ [Test]
+ public void ContainsLegacyAssemblyScopedApi_WhenLegacyDomainHelpersAreUsed_ReturnsTrue()
+ {
+ // Verifies that split files using Domain helpers are migrated with their legacy assembly.
+ string source = "public static class ToolHelper { public static ServiceResult Create() => null; }";
+
+ bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi(
+ source,
+ System.Array.Empty());
+
+ Assert.That(containsLegacyApi, Is.True);
+ }
+
+ [Test]
+ public void MigrateCSharpSource_WhenLegacyNamespaceLikeTextExists_KeepsContent()
+ {
+ // Verifies that namespace migration treats dots as literal characters.
+ string source = "using ioXgithubYhatayamaZUnityCliLoop;";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSource(source);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenLegacyReferenceIsUsed_RewritesToToolContractsGuid()
+ {
+ // Verifies that a custom tool asmdef points at the V3 ToolContracts assembly.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""uLoopMCP.Editor""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: false,
+ requiresToolContractsReference: false,
+ requiresApplicationReference: false,
+ requiresDomainReference: false);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(result.Content, Does.Not.Contain("uLoopMCP.Editor"));
+ Assert.That(result.ReplacementCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenOnlyLegacyGuidIsUsedWithoutLegacySource_KeepsContent()
+ {
+ // Verifies that package-owned Application references are not rewritten by GUID alone.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: false,
+ requiresToolContractsReference: false,
+ requiresApplicationReference: false,
+ requiresDomainReference: false);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenLegacyGuidIsUsedWithLegacySource_RewritesToToolContractsGuid()
+ {
+ // Verifies that old custom tool assemblies with GUID references are migrated when source confirms old API usage.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: true,
+ requiresToolContractsReference: false,
+ requiresApplicationReference: false,
+ requiresDomainReference: false);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(result.Content, Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenLegacySourceHasNoReferencesArray_AddsToolContractsReference()
+ {
+ // Verifies that valid minimal asmdefs receive references needed by migrated source files.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor""
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: true,
+ requiresToolContractsReference: false,
+ requiresApplicationReference: false,
+ requiresDomainReference: false);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain(@"""references"": ["));
+ Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenCurrentToolContractsUsesApplicationGuid_AddsToolContractsGuid()
+ {
+ // Verifies that partially migrated custom tool assemblies keep current Application refs by GUID.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: false,
+ requiresToolContractsReference: true,
+ requiresApplicationReference: false,
+ requiresDomainReference: false);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(result.Content, Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenCurrentApplicationGuidAlreadyExists_DoesNotAddDomainReference()
+ {
+ // Verifies that current V3 Application refs do not look like pending legacy migration.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d"",
+ ""GUID:fc3fd32eddbee40e39c2d76dc184957b""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: false,
+ requiresToolContractsReference: true,
+ requiresApplicationReference: true,
+ requiresDomainReference: false);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ Assert.That(result.Content, Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenManualRegistrationIsUsed_KeepsApplicationReference()
+ {
+ // Verifies that migrated manual registration code can reference the V3 registrar assembly.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: true,
+ requiresToolContractsReference: false,
+ requiresApplicationReference: true,
+ requiresDomainReference: false);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(result.Content, Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ Assert.That(result.Content, Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenDomainMetadataIsUsed_AddsDomainReference()
+ {
+ // Verifies that ToolInfo-only helper assemblies can resolve the V3 Domain metadata type.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""GUID:214998e563c124e8a88199b2dd1f522d""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: true,
+ requiresToolContractsReference: false,
+ requiresApplicationReference: false,
+ requiresDomainReference: true);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(result.Content, Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ Assert.That(result.Content, Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenCurrentDomainMetadataRequiresDomainReference_AddsToolContractsReference()
+ {
+ // Verifies that direct V3 Domain consumers also receive transitive ToolContracts access.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": []
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: false,
+ requiresToolContractsReference: false,
+ requiresApplicationReference: false,
+ requiresDomainReference: true);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(result.Content, Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ Assert.That(result.Content, Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d"));
+ }
+
+ [Test]
+ public void MigrateAsmdefSource_WhenCurrentReferencesUseAssemblyNames_DoesNotAddDuplicateGuids()
+ {
+ // Verifies that name-based V3 references are treated as the same assemblies as their GUID references.
+ string source = @"{
+ ""name"": ""MyCompany.Tools.Editor"",
+ ""references"": [
+ ""UnityCLILoop.ToolContracts"",
+ ""UnityCLILoop.Application"",
+ ""UnityCLILoop.Domain""
+ ]
+}";
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource: false,
+ requiresToolContractsReference: true,
+ requiresApplicationReference: true,
+ requiresDomainReference: true);
+
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Content, Is.EqualTo(source));
+ Assert.That(result.Content, Does.Not.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b"));
+ Assert.That(result.Content, Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb"));
+ }
+
+ [Test]
+ public void ContainsLegacyCSharpApi_WhenLegacyToolApiExists_ReturnsTrue()
+ {
+ // Verifies that migration detection is based on public custom tool API usage.
+ string source = "using io.github.hatayama.uLoopMCP;\n" +
+ "[McpTool] public sealed class HelloTool : AbstractUnityTool {}";
+
+ bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source);
+
+ Assert.That(containsLegacyApi, Is.True);
+ }
+
+ [Test]
+ public void ContainsLegacyCSharpApi_WhenLegacyToolApiExistsOnlyInStringLiteral_ReturnsFalse()
+ {
+ // Verifies that inert fixture text does not trigger project migration UI.
+ string source = "public const string Example = \"[McpTool]\";";
+
+ bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source);
+
+ Assert.That(containsLegacyApi, Is.False);
+ }
+
+ [Test]
+ public void ContainsLegacyCSharpApi_WhenLegacyToolApiExistsOnlyInComment_ReturnsFalse()
+ {
+ // Verifies that comments do not trigger project migration UI.
+ string source = "// CustomToolManager";
+
+ bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source);
+
+ Assert.That(containsLegacyApi, Is.False);
+ }
+
+ [Test]
+ public void ContainsMigrationCandidateText_WhenPlainSourceExists_ReturnsFalse()
+ {
+ // Verifies that unrelated source files can skip expensive migration parsing.
+ string source = "public sealed class PlainEditorUtility { public int Count; }";
+
+ bool containsCandidateText = ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source);
+
+ Assert.That(containsCandidateText, Is.False);
+ }
+
+ [Test]
+ public void ContainsMigrationCandidateText_WhenLegacyNamespaceExists_ReturnsTrue()
+ {
+ // Verifies that old custom tool API source still enters migration parsing.
+ string source = "using io.github.hatayama.uLoopMCP;";
+
+ bool containsCandidateText = ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source);
+
+ Assert.That(containsCandidateText, Is.True);
+ }
+
+ [Test]
+ public void ContainsLegacyCSharpApi_WhenGenericLegacyNameExistsWithoutLegacyMarker_ReturnsFalse()
+ {
+ // Verifies that unrelated source names do not trigger project migration UI.
+ string source = "public sealed class CustomToolManager {}";
+
+ bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source);
+
+ Assert.That(containsLegacyApi, Is.False);
+ }
+
+ [Test]
+ public void ContainsLegacyCSharpApi_WhenOnlyCurrentApiExists_ReturnsFalse()
+ {
+ // Verifies that already migrated V3 tools are not detected again.
+ string source = "[UnityCliLoopTool] public sealed class HelloTool : UnityCliLoopTool {}";
+
+ bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source);
+
+ Assert.That(containsLegacyApi, Is.False);
+ }
+
+ [Test]
+ public void GetExcludedDirectoryNames_IncludesUnityGeneratedDirectories()
+ {
+ // Verifies that generated Unity folders are skipped during project-wide scans.
+ string[] names = ThirdPartyToolMigrationRules.GetExcludedDirectoryNames();
+
+ Assert.That(names.Contains("Library"), Is.True);
+ Assert.That(names.Contains("Temp"), Is.True);
+ Assert.That(names.Contains(".git"), Is.True);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta
new file mode 100644
index 000000000..3a1a57758
--- /dev/null
+++ b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 421cf9412b7894ec5b5da141229f5598
+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
new file mode 100644
index 000000000..7a39535da
--- /dev/null
+++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs
@@ -0,0 +1,151 @@
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.Domain;
+using io.github.hatayama.UnityCliLoop.Presentation;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies the dedicated V3 custom tool migration wizard.
+ ///
+ public sealed class ThirdPartyToolMigrationWizardWindowTests
+ {
+ [TestCase(true, true)]
+ [TestCase(false, false)]
+ public void ShouldAutoShowForMigrationTargets_ReturnsDetectionResult(
+ bool hasMigrationTargets,
+ bool expected)
+ {
+ // Verifies that migration startup is controlled only by migration target detection.
+ bool shouldAutoShow =
+ ThirdPartyToolMigrationWizardWindow.ShouldAutoShowForMigrationTargets(hasMigrationTargets);
+
+ Assert.That(shouldAutoShow, Is.EqualTo(expected));
+ }
+
+ [TestCase(
+ 1,
+ "1 file needs V3 custom tool migration.\n" +
+ "The Unity Console is showing errors because this file still uses the old custom tool API.\n\n" +
+ "Click Migrate to update it automatically. The errors should disappear after migration.")]
+ [TestCase(
+ 3,
+ "3 files need V3 custom tool migration.\n" +
+ "The Unity Console is showing errors because these files still use the old custom tool API.\n\n" +
+ "Click Migrate to update them automatically. The errors should disappear after migration.")]
+ public void GetMigrationStatusText_WhenTargetsExist_ReturnsFileCount(
+ int fileCount,
+ string expectedText)
+ {
+ // Verifies that the migration wizard summarizes detected V2 custom tool files.
+ string text = ThirdPartyToolMigrationWizardWindow.GetMigrationStatusText(fileCount);
+
+ Assert.That(text, Is.EqualTo(expectedText));
+ }
+
+ [Test]
+ public void GetMigrationProgressText_WhenProgressExists_ReturnsCheckCount()
+ {
+ // Verifies that the migration wizard reports scan progress while migration targets are unknown.
+ ThirdPartyToolMigrationProgress progress = new(3, 12);
+
+ string text = ThirdPartyToolMigrationWizardWindow.GetMigrationProgressText(progress);
+
+ Assert.That(
+ text,
+ Is.EqualTo("Scanning project for V3 custom tool migration...\n3/12 checks complete."));
+ }
+
+ [TestCase(false, true, "Migrate")]
+ [TestCase(true, true, "Migrating...")]
+ [TestCase(false, false, "Nothing to migrate")]
+ public void GetMigrationButtonText_ReturnsExpectedLabel(
+ bool isMigrating,
+ bool hasMigrationTargets,
+ string expectedLabel)
+ {
+ // Verifies that the migration action communicates its current state.
+ string label = ThirdPartyToolMigrationWizardWindow.GetMigrationButtonText(
+ isMigrating,
+ hasMigrationTargets);
+
+ Assert.That(label, Is.EqualTo(expectedLabel));
+ }
+
+ [Test]
+ public void PrepareForOpen_PopulatesWindowStateBeforeShowing()
+ {
+ // Verifies that startup-created migration windows can preview immediately after CreateGUI.
+ ThirdPartyToolMigrationWizardWindow window =
+ ScriptableObject.CreateInstance();
+ try
+ {
+ Rect position = new(12f, 34f, 360f, 220f);
+
+ ThirdPartyToolMigrationWizardWindow.PrepareForOpen(
+ window,
+ "Unity CLI Loop Migration",
+ position,
+ true);
+
+ SerializedObject serializedWindow = new(window);
+ SerializedProperty refreshProperty =
+ serializedWindow.FindProperty("_shouldRefreshAfterCreateGui");
+
+ Assert.That(window.titleContent.text, Is.EqualTo("Unity CLI Loop Migration"));
+ Assert.That(window.position, Is.EqualTo(position));
+ Assert.That(window.minSize, Is.EqualTo(new Vector2(360f, 120f)));
+ Assert.That(refreshProperty, Is.Not.Null);
+ Assert.That(refreshProperty.boolValue, Is.True);
+ }
+ finally
+ {
+ Object.DestroyImmediate(window);
+ }
+ }
+
+ [Test]
+ public void WithContentHeight_UsesMeasuredHeightAndPreservesCenter()
+ {
+ // Verifies that the migration wizard resizes vertically from measured content height.
+ Rect initialRect = new(123f, 456f, 400f, 220f);
+ Vector2 frameSize = new(18f, 28f);
+
+ Rect resizedRect =
+ ThirdPartyToolMigrationWizardWindow.WithContentHeight(initialRect, 180f, frameSize);
+
+ Assert.That(resizedRect.center, Is.EqualTo(initialRect.center));
+ Assert.That(resizedRect.size, Is.EqualTo(new Vector2(360f, 208f)));
+ }
+
+ [Test]
+ public void WithContentHeight_WhenMeasuredHeightIsSmall_ClampsToMinimumHeight()
+ {
+ // Verifies that content fitting keeps the migration wizard from becoming unusably short.
+ Rect initialRect = new(123f, 456f, 400f, 220f);
+ Vector2 frameSize = new(18f, 28f);
+
+ Rect resizedRect =
+ ThirdPartyToolMigrationWizardWindow.WithContentHeight(initialRect, 12f, frameSize);
+
+ Assert.That(resizedRect.center, Is.EqualTo(initialRect.center));
+ Assert.That(resizedRect.size, Is.EqualTo(new Vector2(360f, 120f)));
+ }
+
+ [Test]
+ public void WithContentHeight_WhenCurrentWidthIsWide_UsesSetupWizardWidth()
+ {
+ // Verifies that content fitting keeps the migration wizard at Setup Wizard width.
+ Rect initialRect = new(123f, 456f, 520f, 220f);
+ Vector2 frameSize = new(18f, 28f);
+
+ Rect resizedRect =
+ ThirdPartyToolMigrationWizardWindow.WithContentHeight(initialRect, 120f, frameSize);
+
+ Assert.That(resizedRect.center, Is.EqualTo(initialRect.center));
+ Assert.That(resizedRect.size, Is.EqualTo(new Vector2(360f, 148f)));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs.meta
new file mode 100644
index 000000000..8aef13903
--- /dev/null
+++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e92d6d0c7ab84404aa057f047ce14328
+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
new file mode 100644
index 000000000..3fcb896ad
--- /dev/null
+++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+
+using io.github.hatayama.UnityCliLoop.Domain;
+
+namespace io.github.hatayama.UnityCliLoop.Application
+{
+ ///
+ /// Coordinates V3 migration for third-party custom tools without owning file-system details.
+ ///
+ public sealed class ThirdPartyToolMigrationUseCase
+ {
+ private readonly IThirdPartyToolMigrationPort _migrationPort;
+
+ public ThirdPartyToolMigrationUseCase(IThirdPartyToolMigrationPort migrationPort)
+ {
+ Debug.Assert(migrationPort != null, "migrationPort must not be null");
+
+ _migrationPort = migrationPort ?? throw new ArgumentNullException(nameof(migrationPort));
+ }
+
+ public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ return _migrationPort.PreviewMigration(projectRoot);
+ }
+
+ public Task PreviewMigrationAsync(
+ string projectRoot,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(progress != null, "progress must not be null");
+
+ return _migrationPort.PreviewMigrationAsync(projectRoot, progress, ct);
+ }
+
+ public Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ return _migrationPort.HasMigrationTargetsAsync(projectRoot, ct);
+ }
+
+ public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ return _migrationPort.ApplyMigration(projectRoot);
+ }
+ }
+}
diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta
new file mode 100644
index 000000000..9586e68b6
--- /dev/null
+++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 792cd64b3299949f9baf51e9a63eea8c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs
new file mode 100644
index 000000000..8f6a5fcec
--- /dev/null
+++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Diagnostics;
+
+namespace io.github.hatayama.UnityCliLoop.Application
+{
+ ///
+ /// Stores the third-party tool migration use case for Unity-created presentation objects.
+ ///
+ internal static class ThirdPartyToolMigrationUseCaseRegistry
+ {
+ private static ThirdPartyToolMigrationUseCase RegisteredUseCase;
+
+ internal static void Register(ThirdPartyToolMigrationUseCase useCase)
+ {
+ Debug.Assert(useCase != null, "useCase must not be null");
+
+ RegisteredUseCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
+ }
+
+ internal static ThirdPartyToolMigrationUseCase GetRegisteredUseCase()
+ {
+ if (RegisteredUseCase == null)
+ {
+ throw new InvalidOperationException("Unity CLI Loop third-party tool migration use case is not registered.");
+ }
+
+ return RegisteredUseCase;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta
new file mode 100644
index 000000000..04db50e14
--- /dev/null
+++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c7e9d9700538b41e0ae2cd7846439cab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs
index bb9db5e38..7601ed5ac 100644
--- a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs
+++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs
@@ -32,6 +32,8 @@ internal UnityCliLoopApplicationServices Register()
toolRegistrarService));
SkillSetupUseCase skillSetupUseCase = new(new SkillSetupService(new ToolSkillSetupService(toolSettingsService)));
SkillSetupUseCaseRegistry.Register(skillSetupUseCase);
+ ThirdPartyToolMigrationUseCaseRegistry.Register(
+ new ThirdPartyToolMigrationUseCase(new ThirdPartyToolMigrationFileService()));
CliSetupApplicationFacade.RegisterService(new CliSetupApplicationService(
new CliInstallationDetector(),
new NativeCliInstallerService()));
diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs
new file mode 100644
index 000000000..f18fc1de9
--- /dev/null
+++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace io.github.hatayama.UnityCliLoop.Domain
+{
+ ///
+ /// Describes pending V3 custom tool migration work found in the Unity project.
+ ///
+ public readonly struct ThirdPartyToolMigrationPreview
+ {
+ private readonly string[] _filePaths;
+
+ public ThirdPartyToolMigrationPreview(int fileCount, int replacementCount, string[] filePaths)
+ {
+ Debug.Assert(fileCount >= 0, "fileCount must not be negative");
+ Debug.Assert(replacementCount >= 0, "replacementCount must not be negative");
+ Debug.Assert(filePaths != null, "filePaths must not be null");
+
+ FileCount = fileCount;
+ ReplacementCount = replacementCount;
+ _filePaths = ThirdPartyToolMigrationFilePathSnapshot.Copy(filePaths);
+ }
+
+ public int FileCount { get; }
+ public int ReplacementCount { get; }
+ public string[] FilePaths => ThirdPartyToolMigrationFilePathSnapshot.Copy(_filePaths);
+ public bool HasTargets => FileCount > 0;
+ }
+
+ ///
+ /// Reports preview scan progress so editor UI can repaint while project files are inspected.
+ ///
+ public readonly struct ThirdPartyToolMigrationProgress
+ {
+ public ThirdPartyToolMigrationProgress(int processedItemCount, int totalItemCount)
+ {
+ Debug.Assert(processedItemCount >= 0, "processedItemCount must not be negative");
+ Debug.Assert(totalItemCount >= 0, "totalItemCount must not be negative");
+ Debug.Assert(
+ processedItemCount <= totalItemCount || totalItemCount == 0,
+ "processedItemCount must not exceed totalItemCount");
+
+ ProcessedItemCount = processedItemCount;
+ TotalItemCount = totalItemCount;
+ }
+
+ public int ProcessedItemCount { get; }
+ public int TotalItemCount { get; }
+ }
+
+ ///
+ /// Describes the files rewritten by the V3 custom tool migration workflow.
+ ///
+ public readonly struct ThirdPartyToolMigrationResult
+ {
+ private readonly string[] _filePaths;
+
+ public ThirdPartyToolMigrationResult(int fileCount, int replacementCount, string[] filePaths)
+ {
+ Debug.Assert(fileCount >= 0, "fileCount must not be negative");
+ Debug.Assert(replacementCount >= 0, "replacementCount must not be negative");
+ Debug.Assert(filePaths != null, "filePaths must not be null");
+
+ FileCount = fileCount;
+ ReplacementCount = replacementCount;
+ _filePaths = ThirdPartyToolMigrationFilePathSnapshot.Copy(filePaths);
+ }
+
+ public int FileCount { get; }
+ public int ReplacementCount { get; }
+ public string[] FilePaths => ThirdPartyToolMigrationFilePathSnapshot.Copy(_filePaths);
+ public bool Changed => FileCount > 0;
+ }
+
+ public interface IThirdPartyToolMigrationPort
+ {
+ ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot);
+ Task PreviewMigrationAsync(
+ string projectRoot,
+ IProgress progress,
+ CancellationToken ct);
+ Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct);
+ ThirdPartyToolMigrationResult ApplyMigration(string projectRoot);
+ }
+
+ ///
+ /// Creates stable migration file path snapshots before value objects store caller-owned arrays.
+ ///
+ internal static class ThirdPartyToolMigrationFilePathSnapshot
+ {
+ internal static string[] Copy(string[] filePaths)
+ {
+ if (filePaths == null || filePaths.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ string[] copy = new string[filePaths.Length];
+ Array.Copy(filePaths, copy, filePaths.Length);
+ return copy;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta
new file mode 100644
index 000000000..d7d4f0942
--- /dev/null
+++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7d34ccf1cea694100982036051096ac6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta
new file mode 100644
index 000000000..e835d3fcc
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b3977ef001d234ff59cf1e4d41a33793
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs
new file mode 100644
index 000000000..1d831b9d0
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs
@@ -0,0 +1,2073 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+using io.github.hatayama.UnityCliLoop.Domain;
+
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Scans Unity project files and rewrites V2 custom tool source to the V3 public contract API.
+ ///
+ public sealed class ThirdPartyToolMigrationFileService : IThirdPartyToolMigrationPort
+ {
+ private const string ImplicitEditorAssemblyDirectoryName = "__UnityCliLoopImplicitEditorAssembly";
+ private const string ImplicitRuntimeAssemblyDirectoryName = "__UnityCliLoopImplicitRuntimeAssembly";
+ private const string ImplicitFirstPassEditorAssemblyDirectoryName =
+ "__UnityCliLoopImplicitFirstPassEditorAssembly";
+ private const string ImplicitFirstPassRuntimeAssemblyDirectoryName =
+ "__UnityCliLoopImplicitFirstPassRuntimeAssembly";
+ private const int PreviewYieldBatchSize = 32;
+
+ private readonly object _previewCacheLock = new();
+ private bool _hasCachedPreview;
+ private string _cachedPreviewProjectRoot = string.Empty;
+ private ThirdPartyToolMigrationPreview _cachedPreview;
+
+ public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string normalizedProjectRoot = NormalizeProjectRoot(projectRoot);
+ if (TryGetCachedPreview(normalizedProjectRoot, out ThirdPartyToolMigrationPreview cachedPreview))
+ {
+ return cachedPreview;
+ }
+
+ MigrationPlan plan = CreateMigrationPlan(normalizedProjectRoot);
+ ThirdPartyToolMigrationPreview preview = new(
+ plan.ChangedFilePaths.Count,
+ plan.ReplacementCount,
+ plan.ChangedFilePaths.ToArray());
+ StoreCachedPreview(normalizedProjectRoot, preview);
+ return preview;
+ }
+
+ public async Task PreviewMigrationAsync(
+ string projectRoot,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(progress != null, "progress must not be null");
+
+ string normalizedProjectRoot = NormalizeProjectRoot(projectRoot);
+ MigrationPlan plan = await CreateMigrationPlanAsync(normalizedProjectRoot, progress, ct);
+ if (ct.IsCancellationRequested)
+ {
+ return new ThirdPartyToolMigrationPreview(0, 0, Array.Empty());
+ }
+
+ ThirdPartyToolMigrationPreview preview = new(
+ plan.ChangedFilePaths.Count,
+ plan.ReplacementCount,
+ plan.ChangedFilePaths.ToArray());
+ StoreCachedPreview(normalizedProjectRoot, preview);
+ return preview;
+ }
+
+ public async Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string normalizedProjectRoot = NormalizeProjectRoot(projectRoot);
+ if (!Directory.Exists(normalizedProjectRoot))
+ {
+ throw new DirectoryNotFoundException(normalizedProjectRoot);
+ }
+
+ if (!Directory.Exists(Path.Combine(normalizedProjectRoot, "Assets")))
+ {
+ return false;
+ }
+
+ return await HasMigrationTargetAsync(normalizedProjectRoot, ct);
+ }
+
+ public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string normalizedProjectRoot = NormalizeProjectRoot(projectRoot);
+ InvalidatePreviewCache();
+ MigrationPlan plan = CreateMigrationPlan(normalizedProjectRoot);
+ foreach (MigrationFileChange change in plan.Changes)
+ {
+ WriteMigrationFile(change.FilePath, change.Content);
+ }
+
+ return new ThirdPartyToolMigrationResult(
+ plan.ChangedFilePaths.Count,
+ plan.ReplacementCount,
+ plan.ChangedFilePaths.ToArray());
+ }
+
+ internal void InvalidatePreviewCache()
+ {
+ lock (_previewCacheLock)
+ {
+ _hasCachedPreview = false;
+ _cachedPreviewProjectRoot = string.Empty;
+ _cachedPreview = default;
+ }
+ }
+
+ private static async Task HasMigrationTargetAsync(string projectRoot, CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ ProjectFileInventory inventory = await ProjectFileInventory.CreateAsync(
+ projectRoot,
+ new Progress(),
+ ct);
+ if (ct.IsCancellationRequested)
+ {
+ return false;
+ }
+
+ List asmdefDirectories = inventory.AsmdefFilePaths
+ .Select(path => Path.GetDirectoryName(path) ?? string.Empty)
+ .Where(path => !string.IsNullOrEmpty(path))
+ .OrderByDescending(path => path.Length)
+ .ToList();
+ List assemblyReferenceDirectories = inventory.AsmrefFilePaths.Count == 0
+ ? new List()
+ : CreateAssemblyReferenceDirectories(inventory.AsmdefFilePaths, inventory.AsmrefFilePaths);
+ HashSet legacyAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet assemblyScopedCurrentDomainDirectories = new(StringComparer.Ordinal);
+ HashSet toolContractsReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet applicationReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet domainReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+ int inspectedEntryCount = 0;
+ foreach (string csharpFilePath in inventory.CSharpFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return false;
+ }
+
+ string source = File.ReadAllText(csharpFilePath);
+ if (ContainsFastCSharpMigrationTarget(source))
+ {
+ return true;
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source))
+ {
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ projectRoot);
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source))
+ {
+ assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory);
+ }
+
+ CollectFastAssemblyReferenceRequirements(
+ source,
+ assemblyDirectory,
+ assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory),
+ legacyAssemblyDirectories,
+ toolContractsReferenceAssemblyDirectories,
+ applicationReferenceAssemblyDirectories,
+ domainReferenceAssemblyDirectories);
+ }
+
+ inspectedEntryCount++;
+ if (inspectedEntryCount % PreviewYieldBatchSize == 0)
+ {
+ await Task.Yield();
+ }
+ }
+
+ if (assemblyScopedCurrentDomainDirectories.Count > 0)
+ {
+ await CollectFastAssemblyScopedCurrentDomainRequirementsAsync(
+ inventory.CSharpFilePaths,
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ projectRoot,
+ assemblyScopedCurrentDomainDirectories,
+ domainReferenceAssemblyDirectories,
+ ct);
+ if (ct.IsCancellationRequested)
+ {
+ return false;
+ }
+ }
+
+ foreach (string asmdefFilePath in inventory.AsmdefFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return false;
+ }
+
+ string source = File.ReadAllText(asmdefFilePath);
+ if (ContainsFastAsmdefMigrationTarget(source))
+ {
+ return true;
+ }
+
+ inspectedEntryCount++;
+ if (inspectedEntryCount % PreviewYieldBatchSize == 0)
+ {
+ await Task.Yield();
+ }
+ }
+
+ if (toolContractsReferenceAssemblyDirectories.Count == 0 &&
+ applicationReferenceAssemblyDirectories.Count == 0 &&
+ domainReferenceAssemblyDirectories.Count == 0)
+ {
+ return false;
+ }
+
+ MigrationAssemblyUsage assemblyUsage = new(
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ legacyAssemblyDirectories,
+ new HashSet(StringComparer.Ordinal),
+ new Dictionary(StringComparer.Ordinal),
+ new Dictionary(StringComparer.Ordinal),
+ toolContractsReferenceAssemblyDirectories,
+ applicationReferenceAssemblyDirectories,
+ domainReferenceAssemblyDirectories);
+
+ foreach (string asmdefFilePath in inventory.AsmdefFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return false;
+ }
+
+ if (ContainsAsmdefMigrationTarget(asmdefFilePath, projectRoot, assemblyUsage))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void CollectFastAssemblyReferenceRequirements(
+ string source,
+ string assemblyDirectory,
+ bool hasAssemblyScopedCurrentDomainNamespaceUsage,
+ HashSet legacyAssemblyDirectories,
+ HashSet toolContractsReferenceAssemblyDirectories,
+ HashSet applicationReferenceAssemblyDirectories,
+ HashSet domainReferenceAssemblyDirectories)
+ {
+ Debug.Assert(source != null, "source must not be null");
+ Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory), "assemblyDirectory must not be null or empty");
+ Debug.Assert(legacyAssemblyDirectories != null, "legacyAssemblyDirectories must not be null");
+ Debug.Assert(
+ toolContractsReferenceAssemblyDirectories != null,
+ "toolContractsReferenceAssemblyDirectories must not be null");
+ Debug.Assert(
+ applicationReferenceAssemblyDirectories != null,
+ "applicationReferenceAssemblyDirectories must not be null");
+ Debug.Assert(domainReferenceAssemblyDirectories != null, "domainReferenceAssemblyDirectories must not be null");
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source))
+ {
+ legacyAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsApi(source))
+ {
+ toolContractsReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApi(source) ||
+ ThirdPartyToolMigrationRules.ContainsCurrentRegistrarApi(source))
+ {
+ applicationReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApi(source))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApi(source) ||
+ ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly(
+ source,
+ hasAssemblyScopedCurrentDomainNamespaceUsage))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+ }
+
+ private static async Task CollectFastAssemblyScopedCurrentDomainRequirementsAsync(
+ List csharpFilePaths,
+ List asmdefDirectories,
+ List assemblyReferenceDirectories,
+ string projectRoot,
+ HashSet assemblyScopedCurrentDomainDirectories,
+ HashSet domainReferenceAssemblyDirectories,
+ CancellationToken ct)
+ {
+ Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null");
+ Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null");
+ Debug.Assert(
+ assemblyReferenceDirectories != null,
+ "assemblyReferenceDirectories must not be null");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(
+ assemblyScopedCurrentDomainDirectories != null,
+ "assemblyScopedCurrentDomainDirectories must not be null");
+ Debug.Assert(domainReferenceAssemblyDirectories != null, "domainReferenceAssemblyDirectories must not be null");
+
+ int inspectedEntryCount = 0;
+ foreach (string csharpFilePath in csharpFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return;
+ }
+
+ string source = File.ReadAllText(csharpFilePath);
+ if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source))
+ {
+ continue;
+ }
+
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ projectRoot);
+ if (assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory) &&
+ ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly(source, true))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ inspectedEntryCount++;
+ if (inspectedEntryCount % PreviewYieldBatchSize == 0)
+ {
+ await Task.Yield();
+ }
+ }
+ }
+
+ private static bool ContainsAsmdefMigrationTarget(
+ string asmdefFilePath,
+ string projectRoot,
+ MigrationAssemblyUsage assemblyUsage)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(asmdefFilePath), "asmdefFilePath must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ bool hasLegacyCSharpSource;
+ bool requiresToolContractsReference;
+ bool requiresApplicationReference;
+ bool requiresDomainReference;
+ bool hasAssemblyMigrationRequirement = TryGetAsmdefMigrationRequirements(
+ asmdefFilePath,
+ projectRoot,
+ assemblyUsage,
+ out hasLegacyCSharpSource,
+ out requiresToolContractsReference,
+ out requiresApplicationReference,
+ out requiresDomainReference);
+ string source = File.ReadAllText(asmdefFilePath);
+ if (!hasAssemblyMigrationRequirement &&
+ !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source))
+ {
+ return false;
+ }
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource,
+ requiresToolContractsReference,
+ requiresApplicationReference,
+ requiresDomainReference);
+
+ return result.Changed;
+ }
+
+ private static bool TryGetAsmdefMigrationRequirements(
+ string asmdefFilePath,
+ string projectRoot,
+ MigrationAssemblyUsage assemblyUsage,
+ out bool hasLegacyCSharpSource,
+ out bool requiresToolContractsReference,
+ out bool requiresApplicationReference,
+ out bool requiresDomainReference)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(asmdefFilePath), "asmdefFilePath must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string asmdefDirectory = Path.GetDirectoryName(asmdefFilePath) ?? projectRoot;
+ hasLegacyCSharpSource = assemblyUsage.LegacyAssemblyDirectories.Contains(asmdefDirectory);
+ requiresToolContractsReference =
+ assemblyUsage.ToolContractsReferenceAssemblyDirectories.Contains(asmdefDirectory);
+ requiresApplicationReference =
+ assemblyUsage.ApplicationReferenceAssemblyDirectories.Contains(asmdefDirectory);
+ requiresDomainReference =
+ assemblyUsage.DomainReferenceAssemblyDirectories.Contains(asmdefDirectory);
+ return hasLegacyCSharpSource ||
+ requiresToolContractsReference ||
+ requiresApplicationReference ||
+ requiresDomainReference;
+ }
+
+ private static bool ContainsFastCSharpMigrationTarget(string source)
+ {
+ Debug.Assert(source != null, "source must not be null");
+
+ return ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source) &&
+ ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source);
+ }
+
+ private static bool ContainsFastAsmdefMigrationTarget(string source)
+ {
+ Debug.Assert(source != null, "source must not be null");
+
+ return ThirdPartyToolMigrationRules.ContainsLegacyAsmdefNameReference(source);
+ }
+
+ private static bool ShouldExcludeFastScanDirectory(string directoryPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty");
+
+ string directoryName = Path.GetFileName(directoryPath);
+ return ThirdPartyToolMigrationRules.IsExcludedDirectoryName(directoryName);
+ }
+
+ private bool TryGetCachedPreview(
+ string projectRoot,
+ out ThirdPartyToolMigrationPreview preview)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ lock (_previewCacheLock)
+ {
+ if (_hasCachedPreview &&
+ string.Equals(_cachedPreviewProjectRoot, projectRoot, StringComparison.Ordinal))
+ {
+ preview = _cachedPreview;
+ return true;
+ }
+ }
+
+ preview = default;
+ return false;
+ }
+
+ private void StoreCachedPreview(string projectRoot, ThirdPartyToolMigrationPreview preview)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ lock (_previewCacheLock)
+ {
+ _cachedPreviewProjectRoot = projectRoot;
+ _cachedPreview = preview;
+ _hasCachedPreview = true;
+ }
+ }
+
+ private static MigrationPlan CreateMigrationPlan(string projectRoot)
+ {
+ if (!Directory.Exists(projectRoot))
+ {
+ throw new DirectoryNotFoundException(projectRoot);
+ }
+
+ ProjectFileInventory inventory = ProjectFileInventory.Create(projectRoot);
+ MigrationAssemblyUsage assemblyUsage = FindMigrationAssemblyUsage(
+ projectRoot,
+ inventory.CSharpFilePaths,
+ inventory.AsmdefFilePaths,
+ inventory.AsmrefFilePaths);
+ List changes = new();
+ int replacementCount = 0;
+ string[] legacyToolInfoAliases = GetAllAssemblyScopedLegacyToolInfoAliases(assemblyUsage);
+
+ foreach (string csharpFilePath in inventory.CSharpFilePaths)
+ {
+ string source = File.ReadAllText(csharpFilePath);
+ if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source) &&
+ !ThirdPartyToolMigrationRules.ContainsLegacyTypeAliasReference(source, legacyToolInfoAliases))
+ {
+ continue;
+ }
+
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ assemblyUsage.AsmdefDirectories,
+ assemblyUsage.AssemblyReferenceDirectories,
+ projectRoot);
+ string[] legacyAssemblyAliases;
+ if (!assemblyUsage.AssemblyScopedLegacyAliasesByDirectory.TryGetValue(
+ assemblyDirectory,
+ out legacyAssemblyAliases))
+ {
+ legacyAssemblyAliases = Array.Empty();
+ }
+ string[] legacyAssemblyToolInfoAliases;
+ if (!assemblyUsage.AssemblyScopedLegacyToolInfoAliasesByDirectory.TryGetValue(
+ assemblyDirectory,
+ out legacyAssemblyToolInfoAliases))
+ {
+ legacyAssemblyToolInfoAliases = Array.Empty();
+ }
+ bool hasLegacyAssemblySource =
+ assemblyUsage.AssemblyScopedLegacyDirectories.Contains(assemblyDirectory) &&
+ ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi(source, legacyAssemblyAliases);
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly(
+ source,
+ hasLegacyAssemblySource,
+ legacyAssemblyAliases,
+ legacyAssemblyToolInfoAliases);
+ if (!result.Changed)
+ {
+ continue;
+ }
+
+ replacementCount += result.ReplacementCount;
+ changes.Add(new MigrationFileChange(csharpFilePath, result.Content));
+ }
+
+ foreach (string asmdefFilePath in inventory.AsmdefFilePaths)
+ {
+ bool hasLegacyCSharpSource;
+ bool requiresToolContractsReference;
+ bool requiresApplicationReference;
+ bool requiresDomainReference;
+ bool hasAssemblyMigrationRequirement = TryGetAsmdefMigrationRequirements(
+ asmdefFilePath,
+ projectRoot,
+ assemblyUsage,
+ out hasLegacyCSharpSource,
+ out requiresToolContractsReference,
+ out requiresApplicationReference,
+ out requiresDomainReference);
+ string source = File.ReadAllText(asmdefFilePath);
+ if (!hasAssemblyMigrationRequirement &&
+ !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source))
+ {
+ continue;
+ }
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource,
+ requiresToolContractsReference,
+ requiresApplicationReference,
+ requiresDomainReference);
+ if (!result.Changed)
+ {
+ continue;
+ }
+
+ replacementCount += result.ReplacementCount;
+ changes.Add(new MigrationFileChange(asmdefFilePath, result.Content));
+ }
+
+ return new MigrationPlan(changes, replacementCount);
+ }
+
+ private static async Task CreateMigrationPlanAsync(
+ string projectRoot,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(progress != null, "progress must not be null");
+
+ if (!Directory.Exists(projectRoot))
+ {
+ throw new DirectoryNotFoundException(projectRoot);
+ }
+
+ ProjectFileInventory inventory = await ProjectFileInventory.CreateAsync(projectRoot, progress, ct);
+ if (ct.IsCancellationRequested)
+ {
+ return MigrationPlan.Empty;
+ }
+
+ MigrationProgressCounter progressCounter = new(GetPreviewWorkItemCount(inventory), progress);
+ MigrationAssemblyUsage assemblyUsage = await FindMigrationAssemblyUsageAsync(
+ projectRoot,
+ inventory.CSharpFilePaths,
+ inventory.AsmdefFilePaths,
+ inventory.AsmrefFilePaths,
+ progressCounter,
+ ct);
+ if (ct.IsCancellationRequested)
+ {
+ return MigrationPlan.Empty;
+ }
+
+ List changes = new();
+ int replacementCount = 0;
+ string[] legacyToolInfoAliases = GetAllAssemblyScopedLegacyToolInfoAliases(assemblyUsage);
+
+ foreach (string csharpFilePath in inventory.CSharpFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return MigrationPlan.Empty;
+ }
+
+ string source = File.ReadAllText(csharpFilePath);
+ await progressCounter.ReportProcessedItemAsync(ct);
+ if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source) &&
+ !ThirdPartyToolMigrationRules.ContainsLegacyTypeAliasReference(source, legacyToolInfoAliases))
+ {
+ continue;
+ }
+
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ assemblyUsage.AsmdefDirectories,
+ assemblyUsage.AssemblyReferenceDirectories,
+ projectRoot);
+ string[] legacyAssemblyAliases;
+ if (!assemblyUsage.AssemblyScopedLegacyAliasesByDirectory.TryGetValue(
+ assemblyDirectory,
+ out legacyAssemblyAliases))
+ {
+ legacyAssemblyAliases = Array.Empty();
+ }
+ string[] legacyAssemblyToolInfoAliases;
+ if (!assemblyUsage.AssemblyScopedLegacyToolInfoAliasesByDirectory.TryGetValue(
+ assemblyDirectory,
+ out legacyAssemblyToolInfoAliases))
+ {
+ legacyAssemblyToolInfoAliases = Array.Empty();
+ }
+ bool hasLegacyAssemblySource =
+ assemblyUsage.AssemblyScopedLegacyDirectories.Contains(assemblyDirectory) &&
+ ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi(source, legacyAssemblyAliases);
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly(
+ source,
+ hasLegacyAssemblySource,
+ legacyAssemblyAliases,
+ legacyAssemblyToolInfoAliases);
+ if (!result.Changed)
+ {
+ continue;
+ }
+
+ replacementCount += result.ReplacementCount;
+ changes.Add(new MigrationFileChange(csharpFilePath, result.Content));
+ }
+
+ foreach (string asmdefFilePath in inventory.AsmdefFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return MigrationPlan.Empty;
+ }
+
+ bool hasLegacyCSharpSource;
+ bool requiresToolContractsReference;
+ bool requiresApplicationReference;
+ bool requiresDomainReference;
+ bool hasAssemblyMigrationRequirement = TryGetAsmdefMigrationRequirements(
+ asmdefFilePath,
+ projectRoot,
+ assemblyUsage,
+ out hasLegacyCSharpSource,
+ out requiresToolContractsReference,
+ out requiresApplicationReference,
+ out requiresDomainReference);
+ string source = File.ReadAllText(asmdefFilePath);
+ await progressCounter.ReportProcessedItemAsync(ct);
+ if (!hasAssemblyMigrationRequirement &&
+ !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source))
+ {
+ continue;
+ }
+
+ ThirdPartyToolMigrationContentResult result =
+ ThirdPartyToolMigrationRules.MigrateAsmdefSource(
+ source,
+ hasLegacyCSharpSource,
+ requiresToolContractsReference,
+ requiresApplicationReference,
+ requiresDomainReference);
+ if (!result.Changed)
+ {
+ continue;
+ }
+
+ replacementCount += result.ReplacementCount;
+ changes.Add(new MigrationFileChange(asmdefFilePath, result.Content));
+ }
+
+ progressCounter.ReportComplete();
+ return new MigrationPlan(changes, replacementCount);
+ }
+
+ private static int GetPreviewWorkItemCount(ProjectFileInventory inventory)
+ {
+ Debug.Assert(inventory != null, "inventory must not be null");
+
+ return (inventory.CSharpFilePaths.Count * 3) +
+ (inventory.AsmdefFilePaths.Count * 2) +
+ inventory.AsmrefFilePaths.Count;
+ }
+
+ private static void WriteMigrationFile(string filePath, string content)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty");
+ Debug.Assert(content != null, "content must not be null");
+
+ string tempFilePath = CreateUniqueSidecarPath(filePath, ".tmp");
+ File.WriteAllText(tempFilePath, content);
+ if (!File.Exists(filePath))
+ {
+ File.Move(tempFilePath, filePath);
+ return;
+ }
+
+ string backupFilePath = CreateUniqueSidecarPath(filePath, ".bak");
+ File.Replace(tempFilePath, filePath, backupFilePath);
+ File.Delete(backupFilePath);
+ }
+
+ private static string CreateUniqueSidecarPath(string filePath, string extension)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(extension), "extension must not be null or empty");
+
+ string directory = Path.GetDirectoryName(filePath) ?? string.Empty;
+ string fileName = Path.GetFileName(filePath);
+ string sidecarPath = Path.Combine(directory, $"{fileName}.{Guid.NewGuid():N}{extension}");
+ while (File.Exists(sidecarPath))
+ {
+ sidecarPath = Path.Combine(directory, $"{fileName}.{Guid.NewGuid():N}{extension}");
+ }
+
+ return sidecarPath;
+ }
+
+ private static string NormalizeProjectRoot(string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ return Path.GetFullPath(projectRoot)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ }
+
+ private static MigrationAssemblyUsage FindMigrationAssemblyUsage(
+ string projectRoot,
+ List csharpFilePaths,
+ List asmdefFilePaths,
+ List asmrefFilePaths)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot 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");
+
+ List asmdefDirectories = asmdefFilePaths
+ .Select(path => Path.GetDirectoryName(path) ?? string.Empty)
+ .Where(path => !string.IsNullOrEmpty(path))
+ .OrderByDescending(path => path.Length)
+ .ToList();
+ List assemblyReferenceDirectories =
+ CreateAssemblyReferenceDirectories(asmdefFilePaths, asmrefFilePaths);
+ HashSet legacyAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet assemblyScopedLegacyDirectories = new(StringComparer.Ordinal);
+ HashSet assemblyScopedCurrentDomainDirectories = new(StringComparer.Ordinal);
+ Dictionary> assemblyScopedLegacyAliasesByDirectory =
+ new(StringComparer.Ordinal);
+ Dictionary> assemblyScopedLegacyToolInfoAliasesByDirectory =
+ new(StringComparer.Ordinal);
+ HashSet registrarAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet domainMetadataAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet toolContractsReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet applicationReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet domainReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+
+ foreach (string csharpFilePath in csharpFilePaths)
+ {
+ string source = File.ReadAllText(csharpFilePath);
+ if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source))
+ {
+ continue;
+ }
+
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ projectRoot);
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source))
+ {
+ legacyAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalUsing(source))
+ {
+ assemblyScopedLegacyDirectories.Add(assemblyDirectory);
+ AddAssemblyScopedLegacyAliases(
+ assemblyScopedLegacyAliasesByDirectory,
+ assemblyDirectory,
+ ThirdPartyToolMigrationRules.GetLegacyGlobalNamespaceAliases(source));
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalToolInfoTypeAlias(source))
+ {
+ AddAssemblyScopedLegacyAliases(
+ assemblyScopedLegacyToolInfoAliasesByDirectory,
+ assemblyDirectory,
+ ThirdPartyToolMigrationRules.GetLegacyGlobalToolInfoTypeAliases(source));
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source))
+ {
+ assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApi(source) ||
+ ThirdPartyToolMigrationRules.ContainsCurrentRegistrarApi(source))
+ {
+ registrarAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApi(source))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsApi(source))
+ {
+ toolContractsReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyDomainMetadataApi(source))
+ {
+ domainMetadataAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApi(source))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+ }
+
+ foreach (string csharpFilePath in csharpFilePaths)
+ {
+ string source = File.ReadAllText(csharpFilePath);
+ if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source))
+ {
+ continue;
+ }
+
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ projectRoot);
+ string[] legacyAssemblyAliases = Array.Empty();
+ if (assemblyScopedLegacyAliasesByDirectory.TryGetValue(
+ assemblyDirectory,
+ out HashSet legacyAssemblyAliasSet))
+ {
+ legacyAssemblyAliases = legacyAssemblyAliasSet
+ .OrderBy(alias => alias, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyDomainHelperApiForAssembly(
+ source,
+ assemblyScopedLegacyDirectories.Contains(assemblyDirectory),
+ legacyAssemblyAliases))
+ {
+ domainMetadataAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApiForAssembly(
+ source,
+ assemblyScopedLegacyDirectories.Contains(assemblyDirectory),
+ legacyAssemblyAliases))
+ {
+ registrarAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApiForAssembly(
+ source,
+ assemblyScopedLegacyDirectories.Contains(assemblyDirectory),
+ legacyAssemblyAliases))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly(
+ source,
+ assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory)))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+ }
+
+ foreach (string registrarAssemblyDirectory in registrarAssemblyDirectories)
+ {
+ applicationReferenceAssemblyDirectories.Add(registrarAssemblyDirectory);
+ }
+
+ foreach (string domainMetadataAssemblyDirectory in domainMetadataAssemblyDirectories)
+ {
+ if (legacyAssemblyDirectories.Contains(domainMetadataAssemblyDirectory))
+ {
+ domainReferenceAssemblyDirectories.Add(domainMetadataAssemblyDirectory);
+ }
+ }
+
+ return new MigrationAssemblyUsage(
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ legacyAssemblyDirectories,
+ assemblyScopedLegacyDirectories,
+ CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyAliasesByDirectory),
+ CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyToolInfoAliasesByDirectory),
+ toolContractsReferenceAssemblyDirectories,
+ applicationReferenceAssemblyDirectories,
+ domainReferenceAssemblyDirectories);
+ }
+
+ private static async Task FindMigrationAssemblyUsageAsync(
+ string projectRoot,
+ List csharpFilePaths,
+ List asmdefFilePaths,
+ List asmrefFilePaths,
+ MigrationProgressCounter progressCounter,
+ CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot 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(progressCounter != null, "progressCounter must not be null");
+
+ List asmdefDirectories = asmdefFilePaths
+ .Select(path => Path.GetDirectoryName(path) ?? string.Empty)
+ .Where(path => !string.IsNullOrEmpty(path))
+ .OrderByDescending(path => path.Length)
+ .ToList();
+ List assemblyReferenceDirectories =
+ await CreateAssemblyReferenceDirectoriesAsync(
+ asmdefFilePaths,
+ asmrefFilePaths,
+ progressCounter,
+ ct);
+ HashSet legacyAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet assemblyScopedLegacyDirectories = new(StringComparer.Ordinal);
+ HashSet assemblyScopedCurrentDomainDirectories = new(StringComparer.Ordinal);
+ Dictionary> assemblyScopedLegacyAliasesByDirectory =
+ new(StringComparer.Ordinal);
+ Dictionary> assemblyScopedLegacyToolInfoAliasesByDirectory =
+ new(StringComparer.Ordinal);
+ HashSet registrarAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet domainMetadataAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet toolContractsReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet applicationReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+ HashSet domainReferenceAssemblyDirectories = new(StringComparer.Ordinal);
+
+ foreach (string csharpFilePath in csharpFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return CreateMigrationAssemblyUsage(
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ legacyAssemblyDirectories,
+ assemblyScopedLegacyDirectories,
+ assemblyScopedLegacyAliasesByDirectory,
+ assemblyScopedLegacyToolInfoAliasesByDirectory,
+ toolContractsReferenceAssemblyDirectories,
+ applicationReferenceAssemblyDirectories,
+ domainReferenceAssemblyDirectories);
+ }
+
+ string source = File.ReadAllText(csharpFilePath);
+ await progressCounter.ReportProcessedItemAsync(ct);
+ if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source))
+ {
+ continue;
+ }
+
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ projectRoot);
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source))
+ {
+ legacyAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalUsing(source))
+ {
+ assemblyScopedLegacyDirectories.Add(assemblyDirectory);
+ AddAssemblyScopedLegacyAliases(
+ assemblyScopedLegacyAliasesByDirectory,
+ assemblyDirectory,
+ ThirdPartyToolMigrationRules.GetLegacyGlobalNamespaceAliases(source));
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalToolInfoTypeAlias(source))
+ {
+ AddAssemblyScopedLegacyAliases(
+ assemblyScopedLegacyToolInfoAliasesByDirectory,
+ assemblyDirectory,
+ ThirdPartyToolMigrationRules.GetLegacyGlobalToolInfoTypeAliases(source));
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source))
+ {
+ assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApi(source) ||
+ ThirdPartyToolMigrationRules.ContainsCurrentRegistrarApi(source))
+ {
+ registrarAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApi(source))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsApi(source))
+ {
+ toolContractsReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyDomainMetadataApi(source))
+ {
+ domainMetadataAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApi(source))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+ }
+
+ foreach (string csharpFilePath in csharpFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return CreateMigrationAssemblyUsage(
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ legacyAssemblyDirectories,
+ assemblyScopedLegacyDirectories,
+ assemblyScopedLegacyAliasesByDirectory,
+ assemblyScopedLegacyToolInfoAliasesByDirectory,
+ toolContractsReferenceAssemblyDirectories,
+ applicationReferenceAssemblyDirectories,
+ domainReferenceAssemblyDirectories);
+ }
+
+ string source = File.ReadAllText(csharpFilePath);
+ await progressCounter.ReportProcessedItemAsync(ct);
+ if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source))
+ {
+ continue;
+ }
+
+ string assemblyDirectory = FindNearestAssemblyDirectory(
+ csharpFilePath,
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ projectRoot);
+ string[] legacyAssemblyAliases = Array.Empty();
+ if (assemblyScopedLegacyAliasesByDirectory.TryGetValue(
+ assemblyDirectory,
+ out HashSet legacyAssemblyAliasSet))
+ {
+ legacyAssemblyAliases = legacyAssemblyAliasSet
+ .OrderBy(alias => alias, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyDomainHelperApiForAssembly(
+ source,
+ assemblyScopedLegacyDirectories.Contains(assemblyDirectory),
+ legacyAssemblyAliases))
+ {
+ domainMetadataAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApiForAssembly(
+ source,
+ assemblyScopedLegacyDirectories.Contains(assemblyDirectory),
+ legacyAssemblyAliases))
+ {
+ registrarAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApiForAssembly(
+ source,
+ assemblyScopedLegacyDirectories.Contains(assemblyDirectory),
+ legacyAssemblyAliases))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+
+ if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly(
+ source,
+ assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory)))
+ {
+ domainReferenceAssemblyDirectories.Add(assemblyDirectory);
+ }
+ }
+
+ foreach (string registrarAssemblyDirectory in registrarAssemblyDirectories)
+ {
+ applicationReferenceAssemblyDirectories.Add(registrarAssemblyDirectory);
+ }
+
+ foreach (string domainMetadataAssemblyDirectory in domainMetadataAssemblyDirectories)
+ {
+ if (legacyAssemblyDirectories.Contains(domainMetadataAssemblyDirectory))
+ {
+ domainReferenceAssemblyDirectories.Add(domainMetadataAssemblyDirectory);
+ }
+ }
+
+ return CreateMigrationAssemblyUsage(
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ legacyAssemblyDirectories,
+ assemblyScopedLegacyDirectories,
+ assemblyScopedLegacyAliasesByDirectory,
+ assemblyScopedLegacyToolInfoAliasesByDirectory,
+ toolContractsReferenceAssemblyDirectories,
+ applicationReferenceAssemblyDirectories,
+ domainReferenceAssemblyDirectories);
+ }
+
+ private static MigrationAssemblyUsage CreateMigrationAssemblyUsage(
+ List asmdefDirectories,
+ List assemblyReferenceDirectories,
+ HashSet legacyAssemblyDirectories,
+ HashSet assemblyScopedLegacyDirectories,
+ Dictionary> assemblyScopedLegacyAliasesByDirectory,
+ Dictionary> assemblyScopedLegacyToolInfoAliasesByDirectory,
+ HashSet toolContractsReferenceAssemblyDirectories,
+ HashSet applicationReferenceAssemblyDirectories,
+ HashSet domainReferenceAssemblyDirectories)
+ {
+ Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null");
+ Debug.Assert(
+ assemblyReferenceDirectories != null,
+ "assemblyReferenceDirectories must not be null");
+ Debug.Assert(legacyAssemblyDirectories != null, "legacyAssemblyDirectories must not be null");
+ Debug.Assert(
+ assemblyScopedLegacyDirectories != null,
+ "assemblyScopedLegacyDirectories must not be null");
+ Debug.Assert(
+ assemblyScopedLegacyAliasesByDirectory != null,
+ "assemblyScopedLegacyAliasesByDirectory must not be null");
+ Debug.Assert(
+ assemblyScopedLegacyToolInfoAliasesByDirectory != null,
+ "assemblyScopedLegacyToolInfoAliasesByDirectory must not be null");
+ Debug.Assert(
+ toolContractsReferenceAssemblyDirectories != null,
+ "toolContractsReferenceAssemblyDirectories must not be null");
+ Debug.Assert(
+ applicationReferenceAssemblyDirectories != null,
+ "applicationReferenceAssemblyDirectories must not be null");
+ Debug.Assert(
+ domainReferenceAssemblyDirectories != null,
+ "domainReferenceAssemblyDirectories must not be null");
+
+ return new MigrationAssemblyUsage(
+ asmdefDirectories,
+ assemblyReferenceDirectories,
+ legacyAssemblyDirectories,
+ assemblyScopedLegacyDirectories,
+ CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyAliasesByDirectory),
+ CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyToolInfoAliasesByDirectory),
+ toolContractsReferenceAssemblyDirectories,
+ applicationReferenceAssemblyDirectories,
+ domainReferenceAssemblyDirectories);
+ }
+
+ private static void AddAssemblyScopedLegacyAliases(
+ Dictionary> aliasesByDirectory,
+ string assemblyDirectory,
+ string[] aliases)
+ {
+ Debug.Assert(aliasesByDirectory != null, "aliasesByDirectory must not be null");
+ Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory), "assemblyDirectory must not be null or empty");
+ Debug.Assert(aliases != null, "aliases must not be null");
+
+ if (aliases.Length == 0)
+ {
+ return;
+ }
+
+ if (!aliasesByDirectory.TryGetValue(assemblyDirectory, out HashSet aliasSet))
+ {
+ aliasSet = new HashSet(StringComparer.Ordinal);
+ aliasesByDirectory.Add(assemblyDirectory, aliasSet);
+ }
+
+ foreach (string alias in aliases)
+ {
+ aliasSet.Add(alias);
+ }
+ }
+
+ private static Dictionary CreateAssemblyScopedLegacyAliasesByDirectory(
+ Dictionary> aliasesByDirectory)
+ {
+ Debug.Assert(aliasesByDirectory != null, "aliasesByDirectory must not be null");
+
+ Dictionary result = new(StringComparer.Ordinal);
+ foreach (KeyValuePair> aliasesForDirectory in aliasesByDirectory)
+ {
+ result.Add(
+ aliasesForDirectory.Key,
+ aliasesForDirectory.Value.OrderBy(alias => alias, StringComparer.Ordinal).ToArray());
+ }
+
+ return result;
+ }
+
+ private static string[] GetAllAssemblyScopedLegacyToolInfoAliases(MigrationAssemblyUsage assemblyUsage)
+ {
+ return assemblyUsage.AssemblyScopedLegacyToolInfoAliasesByDirectory.Values
+ .SelectMany(aliases => aliases)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ private static List CreateAssemblyReferenceDirectories(
+ List asmdefFilePaths,
+ List asmrefFilePaths)
+ {
+ Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null");
+ Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null");
+
+ if (asmrefFilePaths.Count == 0)
+ {
+ return new List();
+ }
+
+ Dictionary asmdefDirectoriesByReference = CreateAsmdefDirectoryMap(asmdefFilePaths);
+ List assemblyReferenceDirectories = new();
+ foreach (string asmrefFilePath in asmrefFilePaths)
+ {
+ if (!TryReadJsonObjectFromFile(asmrefFilePath, out JObject asmref))
+ {
+ continue;
+ }
+
+ string reference = asmref["reference"]?.Value() ?? string.Empty;
+ if (reference.Length == 0)
+ {
+ continue;
+ }
+
+ if (!asmdefDirectoriesByReference.TryGetValue(reference, out string targetAssemblyDirectory))
+ {
+ continue;
+ }
+
+ string sourceDirectory = Path.GetDirectoryName(asmrefFilePath) ?? string.Empty;
+ if (sourceDirectory.Length == 0)
+ {
+ continue;
+ }
+
+ assemblyReferenceDirectories.Add(
+ new AssemblyReferenceDirectory(sourceDirectory, targetAssemblyDirectory));
+ }
+
+ return assemblyReferenceDirectories
+ .OrderByDescending(assemblyReferenceDirectory => assemblyReferenceDirectory.SourceDirectory.Length)
+ .ToList();
+ }
+
+ private static async Task> CreateAssemblyReferenceDirectoriesAsync(
+ List asmdefFilePaths,
+ List asmrefFilePaths,
+ MigrationProgressCounter progressCounter,
+ CancellationToken ct)
+ {
+ Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null");
+ Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null");
+ Debug.Assert(progressCounter != null, "progressCounter must not be null");
+
+ if (asmrefFilePaths.Count == 0)
+ {
+ return new List();
+ }
+
+ Dictionary asmdefDirectoriesByReference =
+ await CreateAsmdefDirectoryMapAsync(asmdefFilePaths, progressCounter, ct);
+ List assemblyReferenceDirectories = new();
+ foreach (string asmrefFilePath in asmrefFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return assemblyReferenceDirectories
+ .OrderByDescending(
+ assemblyReferenceDirectory => assemblyReferenceDirectory.SourceDirectory.Length)
+ .ToList();
+ }
+
+ if (!TryReadJsonObjectFromFile(asmrefFilePath, out JObject asmref))
+ {
+ await progressCounter.ReportProcessedItemAsync(ct);
+ continue;
+ }
+
+ await progressCounter.ReportProcessedItemAsync(ct);
+ string reference = asmref["reference"]?.Value() ?? string.Empty;
+ if (reference.Length == 0)
+ {
+ continue;
+ }
+
+ if (!asmdefDirectoriesByReference.TryGetValue(reference, out string targetAssemblyDirectory))
+ {
+ continue;
+ }
+
+ string sourceDirectory = Path.GetDirectoryName(asmrefFilePath) ?? string.Empty;
+ if (sourceDirectory.Length == 0)
+ {
+ continue;
+ }
+
+ assemblyReferenceDirectories.Add(
+ new AssemblyReferenceDirectory(sourceDirectory, targetAssemblyDirectory));
+ }
+
+ return assemblyReferenceDirectories
+ .OrderByDescending(assemblyReferenceDirectory => assemblyReferenceDirectory.SourceDirectory.Length)
+ .ToList();
+ }
+
+ private static Dictionary CreateAsmdefDirectoryMap(List asmdefFilePaths)
+ {
+ Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null");
+
+ Dictionary asmdefDirectoriesByReference = new(StringComparer.Ordinal);
+ foreach (string asmdefFilePath in asmdefFilePaths)
+ {
+ string asmdefDirectory = Path.GetDirectoryName(asmdefFilePath) ?? string.Empty;
+ if (asmdefDirectory.Length == 0)
+ {
+ continue;
+ }
+
+ if (!TryReadJsonObjectFromFile(asmdefFilePath, out JObject asmdef))
+ {
+ continue;
+ }
+
+ string assemblyName = asmdef["name"]?.Value() ?? string.Empty;
+ AddAsmdefDirectoryReference(asmdefDirectoriesByReference, assemblyName, asmdefDirectory);
+ AddAsmdefDirectoryReference(
+ asmdefDirectoriesByReference,
+ ReadAsmdefGuidReference(asmdefFilePath),
+ asmdefDirectory);
+ }
+
+ return asmdefDirectoriesByReference;
+ }
+
+ private static async Task> CreateAsmdefDirectoryMapAsync(
+ List asmdefFilePaths,
+ MigrationProgressCounter progressCounter,
+ CancellationToken ct)
+ {
+ Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null");
+ Debug.Assert(progressCounter != null, "progressCounter must not be null");
+
+ Dictionary asmdefDirectoriesByReference = new(StringComparer.Ordinal);
+ foreach (string asmdefFilePath in asmdefFilePaths)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return asmdefDirectoriesByReference;
+ }
+
+ string asmdefDirectory = Path.GetDirectoryName(asmdefFilePath) ?? string.Empty;
+ if (asmdefDirectory.Length == 0)
+ {
+ await progressCounter.ReportProcessedItemAsync(ct);
+ continue;
+ }
+
+ if (!TryReadJsonObjectFromFile(asmdefFilePath, out JObject asmdef))
+ {
+ await progressCounter.ReportProcessedItemAsync(ct);
+ continue;
+ }
+
+ await progressCounter.ReportProcessedItemAsync(ct);
+ string assemblyName = asmdef["name"]?.Value() ?? string.Empty;
+ AddAsmdefDirectoryReference(asmdefDirectoriesByReference, assemblyName, asmdefDirectory);
+ AddAsmdefDirectoryReference(
+ asmdefDirectoriesByReference,
+ ReadAsmdefGuidReference(asmdefFilePath),
+ asmdefDirectory);
+ }
+
+ return asmdefDirectoriesByReference;
+ }
+
+ private static bool TryReadJsonObjectFromFile(string filePath, out JObject jsonObject)
+ {
+ return TryReadJsonObjectForMigration(filePath, File.ReadAllText, out jsonObject);
+ }
+
+ internal static bool TryReadJsonObjectForMigration(
+ string filePath,
+ Func readAllText,
+ out JObject jsonObject)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty");
+ Debug.Assert(readAllText != null, "readAllText must not be null");
+
+ try
+ {
+ jsonObject = JObject.Parse(readAllText(filePath));
+ return true;
+ }
+ catch (Exception ex) when (IsSkippableAssemblyJsonReadException(ex))
+ {
+ UnityEngine.Debug.LogWarning(
+ $"[UnityCliLoop] Skipping unreadable or malformed assembly JSON at {filePath}: {ex.Message}");
+ jsonObject = null;
+ return false;
+ }
+ }
+
+ private static bool IsSkippableAssemblyJsonReadException(Exception ex)
+ {
+ Debug.Assert(ex != null, "ex must not be null");
+
+ return ex is JsonException ||
+ ex is IOException ||
+ ex is UnauthorizedAccessException;
+ }
+
+ private static void AddAsmdefDirectoryReference(
+ Dictionary asmdefDirectoriesByReference,
+ string reference,
+ string asmdefDirectory)
+ {
+ Debug.Assert(asmdefDirectoriesByReference != null, "asmdefDirectoriesByReference must not be null");
+ Debug.Assert(reference != null, "reference must not be null");
+ Debug.Assert(!string.IsNullOrEmpty(asmdefDirectory), "asmdefDirectory must not be null or empty");
+
+ if (reference.Length == 0 || asmdefDirectoriesByReference.ContainsKey(reference))
+ {
+ return;
+ }
+
+ asmdefDirectoriesByReference.Add(reference, asmdefDirectory);
+ }
+
+ private static string ReadAsmdefGuidReference(string asmdefFilePath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(asmdefFilePath), "asmdefFilePath must not be null or empty");
+
+ string metaPath = asmdefFilePath + ".meta";
+ if (!File.Exists(metaPath))
+ {
+ return string.Empty;
+ }
+
+ foreach (string line in File.ReadLines(metaPath))
+ {
+ string trimmedLine = line.Trim();
+ if (!trimmedLine.StartsWith("guid:", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ string guid = trimmedLine.Substring("guid:".Length).Trim();
+ return guid.Length == 0 ? string.Empty : $"GUID:{guid}";
+ }
+
+ return string.Empty;
+ }
+
+ private static string FindNearestAssemblyDirectory(
+ string csharpFilePath,
+ List asmdefDirectories,
+ List assemblyReferenceDirectories,
+ string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty");
+ Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null");
+ Debug.Assert(
+ assemblyReferenceDirectories != null,
+ "assemblyReferenceDirectories must not be null");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string csharpDirectory = Path.GetDirectoryName(csharpFilePath) ?? string.Empty;
+ string matchedAssemblyDirectory = string.Empty;
+ int matchedSourceDirectoryLength = -1;
+ foreach (string asmdefDirectory in asmdefDirectories)
+ {
+ if (!IsSameOrChildPath(csharpDirectory, asmdefDirectory) ||
+ asmdefDirectory.Length <= matchedSourceDirectoryLength)
+ {
+ continue;
+ }
+
+ matchedAssemblyDirectory = asmdefDirectory;
+ matchedSourceDirectoryLength = asmdefDirectory.Length;
+ }
+
+ foreach (AssemblyReferenceDirectory assemblyReferenceDirectory in assemblyReferenceDirectories)
+ {
+ string sourceDirectory = assemblyReferenceDirectory.SourceDirectory;
+ if (!IsSameOrChildPath(csharpDirectory, sourceDirectory) ||
+ sourceDirectory.Length <= matchedSourceDirectoryLength)
+ {
+ continue;
+ }
+
+ matchedAssemblyDirectory = assemblyReferenceDirectory.TargetAssemblyDirectory;
+ matchedSourceDirectoryLength = sourceDirectory.Length;
+ }
+
+ if (matchedAssemblyDirectory.Length > 0)
+ {
+ return matchedAssemblyDirectory;
+ }
+
+ return GetImplicitAssemblyDirectory(csharpFilePath, projectRoot);
+ }
+
+ private static string GetImplicitAssemblyDirectory(string csharpFilePath, string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ bool isEditorAssemblyPath = IsEditorAssemblyPath(csharpFilePath, projectRoot);
+ bool isFirstPassAssemblyPath = IsFirstPassAssemblyPath(csharpFilePath, projectRoot);
+ string implicitAssemblyDirectoryName = GetImplicitAssemblyDirectoryName(
+ isEditorAssemblyPath,
+ isFirstPassAssemblyPath);
+ return Path.Combine(projectRoot, implicitAssemblyDirectoryName);
+ }
+
+ private static string GetImplicitAssemblyDirectoryName(
+ bool isEditorAssemblyPath,
+ bool isFirstPassAssemblyPath)
+ {
+ if (isEditorAssemblyPath && isFirstPassAssemblyPath)
+ {
+ return ImplicitFirstPassEditorAssemblyDirectoryName;
+ }
+
+ if (isFirstPassAssemblyPath)
+ {
+ return ImplicitFirstPassRuntimeAssemblyDirectoryName;
+ }
+
+ return isEditorAssemblyPath
+ ? ImplicitEditorAssemblyDirectoryName
+ : ImplicitRuntimeAssemblyDirectoryName;
+ }
+
+ private static bool IsEditorAssemblyPath(string csharpFilePath, string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string[] pathSegments = GetRelativePathSegments(csharpFilePath, projectRoot);
+ return pathSegments.Any(
+ pathSegment => string.Equals(pathSegment, "Editor", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static bool IsFirstPassAssemblyPath(string csharpFilePath, string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string[] pathSegments = GetRelativePathSegments(csharpFilePath, projectRoot);
+ if (pathSegments.Length < 2 ||
+ !string.Equals(pathSegments[0], "Assets", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return string.Equals(pathSegments[1], "Plugins", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(pathSegments[1], "Standard Assets", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(pathSegments[1], "Pro Standard Assets", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string[] GetRelativePathSegments(string filePath, string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ string relativePath = filePath.StartsWith(projectRoot, StringComparison.Ordinal)
+ ? filePath.Substring(projectRoot.Length)
+ : filePath;
+ char[] separators =
+ {
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ };
+ return relativePath.Split(separators, StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ private static bool IsSameOrChildPath(string childPath, string parentPath)
+ {
+ Debug.Assert(childPath != null, "childPath must not be null");
+ Debug.Assert(parentPath != null, "parentPath must not be null");
+
+ if (string.Equals(childPath, parentPath, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ string parentWithSeparator = parentPath.TrimEnd(Path.DirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
+ return childPath.StartsWith(parentWithSeparator, StringComparison.Ordinal);
+ }
+
+ private readonly struct MigrationFileChange
+ {
+ public MigrationFileChange(string filePath, string content)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty");
+ Debug.Assert(content != null, "content must not be null");
+
+ FilePath = filePath;
+ Content = content ?? string.Empty;
+ }
+
+ public string FilePath { get; }
+ public string Content { get; }
+ }
+
+ private readonly struct MigrationPlan
+ {
+ public static MigrationPlan Empty => new(new List(), 0);
+
+ public MigrationPlan(List changes, int replacementCount)
+ {
+ Debug.Assert(changes != null, "changes must not be null");
+ Debug.Assert(replacementCount >= 0, "replacementCount must not be negative");
+
+ Changes = changes ?? new List();
+ ReplacementCount = replacementCount;
+ ChangedFilePaths = Changes
+ .Select(change => change.FilePath)
+ .OrderBy(path => path, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ public List Changes { get; }
+ public int ReplacementCount { get; }
+ public List ChangedFilePaths { get; }
+ }
+
+ private sealed class MigrationProgressCounter
+ {
+ private readonly IProgress _progress;
+ private readonly int _totalItemCount;
+ private int _processedItemCount;
+
+ public MigrationProgressCounter(
+ int totalItemCount,
+ IProgress progress)
+ {
+ Debug.Assert(totalItemCount >= 0, "totalItemCount must not be negative");
+ Debug.Assert(progress != null, "progress must not be null");
+
+ _totalItemCount = totalItemCount;
+ _progress = progress;
+ Report();
+ }
+
+ public async Task ReportProcessedItemAsync(CancellationToken ct)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return;
+ }
+
+ _processedItemCount++;
+ Report();
+ if (_processedItemCount % PreviewYieldBatchSize != 0)
+ {
+ return;
+ }
+
+ await Task.Yield();
+ }
+
+ public void ReportComplete()
+ {
+ _processedItemCount = _totalItemCount;
+ Report();
+ }
+
+ private void Report()
+ {
+ _progress.Report(
+ new ThirdPartyToolMigrationProgress(
+ Math.Min(_processedItemCount, _totalItemCount),
+ _totalItemCount));
+ }
+ }
+
+ private readonly struct MigrationAssemblyUsage
+ {
+ public MigrationAssemblyUsage(
+ List asmdefDirectories,
+ List assemblyReferenceDirectories,
+ HashSet legacyAssemblyDirectories,
+ HashSet assemblyScopedLegacyDirectories,
+ Dictionary assemblyScopedLegacyAliasesByDirectory,
+ Dictionary assemblyScopedLegacyToolInfoAliasesByDirectory,
+ HashSet toolContractsReferenceAssemblyDirectories,
+ HashSet applicationReferenceAssemblyDirectories,
+ HashSet domainReferenceAssemblyDirectories)
+ {
+ Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null");
+ Debug.Assert(
+ assemblyReferenceDirectories != null,
+ "assemblyReferenceDirectories must not be null");
+ Debug.Assert(legacyAssemblyDirectories != null, "legacyAssemblyDirectories must not be null");
+ Debug.Assert(
+ assemblyScopedLegacyDirectories != null,
+ "assemblyScopedLegacyDirectories must not be null");
+ Debug.Assert(
+ assemblyScopedLegacyAliasesByDirectory != null,
+ "assemblyScopedLegacyAliasesByDirectory must not be null");
+ Debug.Assert(
+ assemblyScopedLegacyToolInfoAliasesByDirectory != null,
+ "assemblyScopedLegacyToolInfoAliasesByDirectory must not be null");
+ Debug.Assert(
+ toolContractsReferenceAssemblyDirectories != null,
+ "toolContractsReferenceAssemblyDirectories must not be null");
+ Debug.Assert(
+ applicationReferenceAssemblyDirectories != null,
+ "applicationReferenceAssemblyDirectories must not be null");
+ Debug.Assert(
+ domainReferenceAssemblyDirectories != null,
+ "domainReferenceAssemblyDirectories must not be null");
+
+ AsmdefDirectories = asmdefDirectories ?? new List();
+ AssemblyReferenceDirectories = assemblyReferenceDirectories ?? new List();
+ LegacyAssemblyDirectories = legacyAssemblyDirectories ?? new HashSet(StringComparer.Ordinal);
+ AssemblyScopedLegacyDirectories = assemblyScopedLegacyDirectories ??
+ new HashSet(StringComparer.Ordinal);
+ AssemblyScopedLegacyAliasesByDirectory = assemblyScopedLegacyAliasesByDirectory ??
+ new Dictionary(StringComparer.Ordinal);
+ AssemblyScopedLegacyToolInfoAliasesByDirectory = assemblyScopedLegacyToolInfoAliasesByDirectory ??
+ new Dictionary(StringComparer.Ordinal);
+ ToolContractsReferenceAssemblyDirectories = toolContractsReferenceAssemblyDirectories ??
+ new HashSet(StringComparer.Ordinal);
+ ApplicationReferenceAssemblyDirectories = applicationReferenceAssemblyDirectories ??
+ new HashSet(StringComparer.Ordinal);
+ DomainReferenceAssemblyDirectories = domainReferenceAssemblyDirectories ??
+ new HashSet(StringComparer.Ordinal);
+ }
+
+ public List AsmdefDirectories { get; }
+ public List AssemblyReferenceDirectories { get; }
+ public HashSet LegacyAssemblyDirectories { get; }
+ public HashSet AssemblyScopedLegacyDirectories { get; }
+ public Dictionary AssemblyScopedLegacyAliasesByDirectory { get; }
+ public Dictionary AssemblyScopedLegacyToolInfoAliasesByDirectory { get; }
+ public HashSet ToolContractsReferenceAssemblyDirectories { get; }
+ public HashSet ApplicationReferenceAssemblyDirectories { get; }
+ public HashSet DomainReferenceAssemblyDirectories { get; }
+ }
+
+ private readonly struct AssemblyReferenceDirectory
+ {
+ public AssemblyReferenceDirectory(string sourceDirectory, string targetAssemblyDirectory)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(sourceDirectory), "sourceDirectory must not be null or empty");
+ Debug.Assert(
+ !string.IsNullOrEmpty(targetAssemblyDirectory),
+ "targetAssemblyDirectory must not be null or empty");
+
+ SourceDirectory = sourceDirectory;
+ TargetAssemblyDirectory = targetAssemblyDirectory;
+ }
+
+ public string SourceDirectory { get; }
+ public string TargetAssemblyDirectory { get; }
+ }
+
+ private sealed class ProjectFileInventory
+ {
+ private ProjectFileInventory(
+ List csharpFilePaths,
+ List asmdefFilePaths,
+ List asmrefFilePaths)
+ {
+ CSharpFilePaths = csharpFilePaths;
+ AsmdefFilePaths = asmdefFilePaths;
+ AsmrefFilePaths = asmrefFilePaths;
+ }
+
+ public List CSharpFilePaths { get; }
+ public List AsmdefFilePaths { get; }
+ public List AsmrefFilePaths { get; }
+
+ public static ProjectFileInventory Create(string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+
+ List csharpFilePaths = new();
+ List asmdefFilePaths = new();
+ List asmrefFilePaths = new();
+ string assetsDirectory = Path.Combine(projectRoot, "Assets");
+ if (Directory.Exists(assetsDirectory))
+ {
+ CollectCandidateFiles(
+ projectRoot,
+ assetsDirectory,
+ csharpFilePaths,
+ asmdefFilePaths,
+ asmrefFilePaths);
+ }
+
+ csharpFilePaths.Sort(StringComparer.Ordinal);
+ asmdefFilePaths.Sort(StringComparer.Ordinal);
+ asmrefFilePaths.Sort(StringComparer.Ordinal);
+ return new ProjectFileInventory(csharpFilePaths, asmdefFilePaths, asmrefFilePaths);
+ }
+
+ public static async Task CreateAsync(
+ string projectRoot,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(progress != null, "progress must not be null");
+
+ List csharpFilePaths = new();
+ List asmdefFilePaths = new();
+ List asmrefFilePaths = new();
+ string assetsDirectory = Path.Combine(projectRoot, "Assets");
+ if (Directory.Exists(assetsDirectory))
+ {
+ await CollectCandidateFilesAsync(
+ projectRoot,
+ assetsDirectory,
+ csharpFilePaths,
+ asmdefFilePaths,
+ asmrefFilePaths,
+ progress,
+ ct);
+ }
+
+ csharpFilePaths.Sort(StringComparer.Ordinal);
+ asmdefFilePaths.Sort(StringComparer.Ordinal);
+ asmrefFilePaths.Sort(StringComparer.Ordinal);
+ return new ProjectFileInventory(csharpFilePaths, asmdefFilePaths, asmrefFilePaths);
+ }
+
+ private static void CollectCandidateFiles(
+ string projectRoot,
+ string directoryPath,
+ List csharpFilePaths,
+ List asmdefFilePaths,
+ List asmrefFilePaths)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath 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");
+
+ foreach (string filePath in Directory.EnumerateFiles(directoryPath))
+ {
+ string extension = Path.GetExtension(filePath);
+ if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase))
+ {
+ csharpFilePaths.Add(filePath);
+ continue;
+ }
+
+ if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase))
+ {
+ asmdefFilePaths.Add(filePath);
+ continue;
+ }
+
+ if (string.Equals(extension, ".asmref", StringComparison.OrdinalIgnoreCase))
+ {
+ asmrefFilePaths.Add(filePath);
+ }
+ }
+
+ foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath))
+ {
+ if (ShouldExcludeDirectory(projectRoot, childDirectoryPath))
+ {
+ continue;
+ }
+
+ CollectCandidateFiles(
+ projectRoot,
+ childDirectoryPath,
+ csharpFilePaths,
+ asmdefFilePaths,
+ asmrefFilePaths);
+ }
+ }
+
+ private static async Task CollectCandidateFilesAsync(
+ string projectRoot,
+ string assetsDirectory,
+ List csharpFilePaths,
+ List asmdefFilePaths,
+ List asmrefFilePaths,
+ IProgress progress,
+ 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(csharpFilePaths != null, "csharpFilePaths must not be null");
+ Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null");
+ Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null");
+ Debug.Assert(progress != null, "progress must not be null");
+
+ Stack pendingDirectories = new();
+ pendingDirectories.Push(assetsDirectory);
+ int inspectedEntryCount = 0;
+ progress.Report(new ThirdPartyToolMigrationProgress(0, 0));
+
+ while (pendingDirectories.Count > 0)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ return;
+ }
+
+ string directoryPath = pendingDirectories.Pop();
+ foreach (string filePath in Directory.EnumerateFiles(directoryPath))
+ {
+ AddCandidateFilePath(filePath, csharpFilePaths, asmdefFilePaths, asmrefFilePaths);
+ inspectedEntryCount++;
+ if (inspectedEntryCount % PreviewYieldBatchSize == 0)
+ {
+ progress.Report(new ThirdPartyToolMigrationProgress(0, 0));
+ await Task.Yield();
+ }
+ }
+
+ foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath))
+ {
+ if (ShouldExcludeDirectory(projectRoot, childDirectoryPath))
+ {
+ continue;
+ }
+
+ pendingDirectories.Push(childDirectoryPath);
+ inspectedEntryCount++;
+ if (inspectedEntryCount % PreviewYieldBatchSize == 0)
+ {
+ progress.Report(new ThirdPartyToolMigrationProgress(0, 0));
+ await Task.Yield();
+ }
+ }
+ }
+ }
+
+ private static void AddCandidateFilePath(
+ string filePath,
+ List csharpFilePaths,
+ List asmdefFilePaths,
+ List asmrefFilePaths)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath 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");
+
+ string extension = Path.GetExtension(filePath);
+ if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase))
+ {
+ csharpFilePaths.Add(filePath);
+ return;
+ }
+
+ if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase))
+ {
+ asmdefFilePaths.Add(filePath);
+ return;
+ }
+
+ if (string.Equals(extension, ".asmref", StringComparison.OrdinalIgnoreCase))
+ {
+ asmrefFilePaths.Add(filePath);
+ }
+ }
+
+ private static bool ShouldExcludeDirectory(string projectRoot, string directoryPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty");
+
+ if (IsProjectRootPackagesDirectory(projectRoot, directoryPath))
+ {
+ return true;
+ }
+
+ string directoryName = Path.GetFileName(directoryPath);
+ return ThirdPartyToolMigrationRules.IsExcludedDirectoryName(directoryName);
+ }
+
+ private static bool IsProjectRootPackagesDirectory(string projectRoot, string directoryPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
+ Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty");
+
+ string packagesDirectory = Path.Combine(projectRoot, "Packages");
+ string normalizedPackagesDirectory = NormalizeDirectoryPath(packagesDirectory);
+ string normalizedDirectoryPath = NormalizeDirectoryPath(directoryPath);
+ return string.Equals(
+ normalizedDirectoryPath,
+ normalizedPackagesDirectory,
+ StringComparison.Ordinal);
+ }
+
+ private static string NormalizeDirectoryPath(string directoryPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty");
+
+ return Path.GetFullPath(directoryPath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ }
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta
new file mode 100644
index 000000000..155b99fe4
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8dee7da723ba64861ba649d28577d373
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs
new file mode 100644
index 000000000..aaf873d04
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs
@@ -0,0 +1,3045 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Applies deterministic source rewrites for V2 custom tools that need the V3 public contract API.
+ ///
+ internal static class ThirdPartyToolMigrationRules
+ {
+ internal const string LegacyNamespace = "io.github.hatayama.uLoopMCP";
+ internal const string CurrentNamespace = "io.github.hatayama.UnityCliLoop.ToolContracts";
+ internal const string CurrentApplicationNamespace = "io.github.hatayama.UnityCliLoop.Application";
+ internal const string CurrentDomainNamespace = "io.github.hatayama.UnityCliLoop.Domain";
+ internal const string LegacyEditorAssemblyName = "uLoopMCP.Editor";
+ private const string CurrentApplicationAssemblyName = "UnityCLILoop.Application";
+ private const string CurrentDomainAssemblyName = "UnityCLILoop.Domain";
+ private const string CurrentToolContractsAssemblyName = "UnityCLILoop.ToolContracts";
+ internal const string LegacyEditorAssemblyGuidReference = "GUID:214998e563c124e8a88199b2dd1f522d";
+ internal const string CurrentApplicationGuidReference = "GUID:214998e563c124e8a88199b2dd1f522d";
+ internal const string CurrentDomainGuidReference = "GUID:5c4588558a3624eacbce0f50007cf1eb";
+ internal const string CurrentToolContractsGuidReference = "GUID:fc3fd32eddbee40e39c2d76dc184957b";
+ private const string DescriptionAttributeArgumentName = "Description";
+ private const string DisplayDevelopmentOnlyAttributeArgumentName = "DisplayDevelopmentOnly";
+ private const string RequiredSecuritySettingAttributeArgumentName = "RequiredSecuritySetting";
+ private const string LegacySecuritySettingsTypeName = "SecuritySettings";
+ private const string CurrentSecuritySettingTypeName = "UnityCliLoopSecuritySetting";
+ private const int MinimumRawStringDelimiterQuoteCount = 3;
+
+ private static readonly string[] ExcludedDirectoryNames =
+ {
+ ".git",
+ "Library",
+ "Temp",
+ "Logs",
+ "obj",
+ "bin",
+ "Build",
+ "Builds"
+ };
+
+ private static readonly string LegacyNamespacePattern =
+ $@"(?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:global::)?{Regex.Escape(LegacyNamespace)}\s*;",
+ RegexOptions.Compiled);
+
+ private static readonly Regex LegacyRegistrarRegex =
+ new(@"\bCustomToolManager\b", RegexOptions.Compiled);
+
+ private static readonly Regex LegacyQualifiedRegistrarRegex =
+ new(
+ $@"(?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:global::)?" +
+ @"io\.github\.hatayama\.uLoopMCP\s*;",
+ RegexOptions.Compiled);
+
+ private static readonly Regex LegacyToolAttributeEntryRegex =
+ new(
+ @"^\s*(?:(?(?:global::)?io\.github\.hatayama\.uLoopMCP\.)|(?[A-Za-z_][A-Za-z0-9_]*)\.)?" +
+ @"McpTool(?:Attribute)?\s*(?:\((?[\s\S]*)\))?\s*$",
+ RegexOptions.Compiled);
+
+ private static readonly Regex LegacyToolInfoConstructorRegex =
+ new(
+ $@"new\s+(?:(?(?:global::)?{Regex.Escape(LegacyNamespace)}\.)ToolInfo|(?[A-Za-z_][A-Za-z0-9_]*)\.ToolInfo|(?