From 2eefcfa9acaedc2afcf3b2373fc58bc53cf511ec Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 10:18:38 +0900 Subject: [PATCH 1/9] fix: Make third-party migration writes an all-or-nothing batch (#1701) Co-authored-by: Cursor --- .../ThirdPartyToolMigrationFileWriterTests.cs | 198 +++++++++++++++ ...dPartyToolMigrationFileWriterTests.cs.meta | 11 + .../ThirdPartyToolMigrationFileService.cs | 15 +- .../ThirdPartyToolMigrationFileWriter.cs | 232 +++++++++++++++++- 4 files changed, 431 insertions(+), 25 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs new file mode 100644 index 000000000..4743f6424 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +using static io.github.hatayama.UnityCliLoop.Infrastructure.ThirdPartyToolMigrationFileServiceConstants; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies batch migration writes commit atomically or roll back. + /// + public sealed class ThirdPartyToolMigrationFileWriterTests + { + [Test] + public void WriteBatch_WhenAllTargetsAreWritable_CommitsEveryFileAndLeavesNoSidecars() + { + // Verifies a mixed existing/new batch commits all targets and leaves no sidecar files. + string tempDirectory = CreateTempDirectory(); + try + { + string existingFile1 = Path.Combine(tempDirectory, "ExistingOne.cs"); + string existingFile2 = Path.Combine(tempDirectory, "ExistingTwo.cs"); + string newFile = Path.Combine(tempDirectory, "NewFile.cs"); + File.WriteAllText(existingFile1, "original-one"); + File.WriteAllText(existingFile2, "original-two"); + + List changes = new() + { + new MigrationFileChange(existingFile1, "migrated-one"), + new MigrationFileChange(existingFile2, "migrated-two"), + new MigrationFileChange(newFile, "migrated-new") + }; + + ThirdPartyToolMigrationFileWriter.WriteBatch(changes); + + Assert.That(File.ReadAllText(existingFile1), Is.EqualTo("migrated-one")); + Assert.That(File.ReadAllText(existingFile2), Is.EqualTo("migrated-two")); + Assert.That(File.ReadAllText(newFile), Is.EqualTo("migrated-new")); + Assert.That(CountSidecarFiles(tempDirectory), Is.EqualTo(0)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + public void WriteBatch_WhenPrepareFails_LeavesAllTargetFilesUntouched() + { + // Verifies a prepare failure leaves every target untouched and removes temp sidecars. + string tempDirectory = CreateTempDirectory(); + try + { + string existingFile = Path.Combine(tempDirectory, "Existing.cs"); + File.WriteAllText(existingFile, "original"); + string missingDirectoryFile = Path.Combine( + tempDirectory, + "missing-directory", + "Missing.cs"); + + List changes = new() + { + new MigrationFileChange(existingFile, "migrated"), + new MigrationFileChange(missingDirectoryFile, "never-written") + }; + + Assert.Throws( + () => ThirdPartyToolMigrationFileWriter.WriteBatch(changes)); + + Assert.That(File.ReadAllText(existingFile), Is.EqualTo("original")); + Assert.That(CountSidecarFiles(tempDirectory), Is.EqualTo(0)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + public void WriteBatch_WhenCommitFails_RestoresCommittedFilesFromBackups() + { + // Verifies a mid-batch commit failure restores already committed files from backups. + string tempDirectory = CreateTempDirectory(); + try + { + string file1 = Path.Combine(tempDirectory, "FileOne.cs"); + string file2 = Path.Combine(tempDirectory, "FileTwo.cs"); + string file3 = Path.Combine(tempDirectory, "FileThree.cs"); + File.WriteAllText(file1, "original-one"); + File.WriteAllText(file3, "original-three"); + Directory.CreateDirectory(file2); + + List changes = new() + { + new MigrationFileChange(file1, "migrated-one"), + new MigrationFileChange(file2, "migrated-two"), + new MigrationFileChange(file3, "migrated-three") + }; + + Assert.Throws( + () => ThirdPartyToolMigrationFileWriter.WriteBatch(changes)); + + Assert.That(File.ReadAllText(file1), Is.EqualTo("original-one")); + Assert.That(File.ReadAllText(file3), Is.EqualTo("original-three")); + Assert.That(CountSidecarFiles(tempDirectory), Is.EqualTo(0)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + public void WriteBatch_WhenCommitFails_DeletesNewlyCreatedFiles() + { + // Verifies rollback removes newly created targets that had no backup sidecar. + string tempDirectory = CreateTempDirectory(); + try + { + string newFile = Path.Combine(tempDirectory, "NewFile.cs"); + string failingTarget = Path.Combine(tempDirectory, "FailingTarget.cs"); + Directory.CreateDirectory(failingTarget); + + List changes = new() + { + new MigrationFileChange(newFile, "migrated-new"), + new MigrationFileChange(failingTarget, "never-committed") + }; + + Assert.Throws( + () => ThirdPartyToolMigrationFileWriter.WriteBatch(changes)); + + Assert.That(File.Exists(newFile), Is.False); + Assert.That(CountSidecarFiles(tempDirectory), Is.EqualTo(0)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + public async Task WriteBatchAsync_WhenBatchExceedsYieldSize_CommitsEveryFile() + { + // Verifies the async prepare yield path still commits every file in a large batch. + string tempDirectory = CreateTempDirectory(); + try + { + List changes = new(); + for (int index = 0; index < PreviewYieldBatchSize + 8; index++) + { + string filePath = Path.Combine(tempDirectory, $"File{index:D2}.cs"); + File.WriteAllText(filePath, $"original-{index}"); + changes.Add(new MigrationFileChange(filePath, $"migrated-{index}")); + } + + await ThirdPartyToolMigrationFileWriter.WriteBatchAsync(changes); + + for (int index = 0; index < changes.Count; index++) + { + Assert.That( + File.ReadAllText(changes[index].FilePath), + Is.EqualTo($"migrated-{index}")); + } + + Assert.That(CountSidecarFiles(tempDirectory), Is.EqualTo(0)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static string CreateTempDirectory() + { + string tempDirectory = Path.Combine( + Path.GetTempPath(), + "UnityCliLoopMigrationWriterTests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + return tempDirectory; + } + + private static int CountSidecarFiles(string directory) + { + return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Count(filePath => + filePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".bak", StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs.meta new file mode 100644 index 000000000..e1c031c44 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e2826ae14e624b0d81d3bf76a647d5b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs index 582fb815e..a9fc880b1 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -92,10 +92,7 @@ public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); MigrationPlan plan = GetCurrentMigrationPlan(normalizedProjectRoot); InvalidatePreviewCache(); - foreach (MigrationFileChange change in plan.Changes) - { - ThirdPartyToolMigrationFileWriter.Write(change.FilePath, change.Content); - } + ThirdPartyToolMigrationFileWriter.WriteBatch(plan.Changes); return new ThirdPartyToolMigrationResult( plan.ChangedFilePaths.Count, @@ -120,15 +117,7 @@ public async Task ApplyMigrationAsync( } InvalidatePreviewCache(); - for (int index = 0; index < plan.Changes.Count; index++) - { - MigrationFileChange change = plan.Changes[index]; - ThirdPartyToolMigrationFileWriter.Write(change.FilePath, change.Content); - if ((index + 1) % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) - { - await Task.Yield(); - } - } + await ThirdPartyToolMigrationFileWriter.WriteBatchAsync(plan.Changes); return new ThirdPartyToolMigrationResult( plan.ChangedFilePaths.Count, diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs index 9cd2875ec..f121d7f3f 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs @@ -2,30 +2,213 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Threading.Tasks; namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// - /// Writes migrated files atomically through temporary sidecar files. + /// Writes a migration plan as one batch transaction: every temp sidecar is written first, + /// then all targets are committed via rename/replace while their backups are kept, and a + /// mid-batch commit failure rolls the already committed files back from those backups. /// internal static class ThirdPartyToolMigrationFileWriter { - internal static void Write(string filePath, string content) + private const string TempSidecarExtension = ".tmp"; + private const string BackupSidecarExtension = ".bak"; + + internal static void WriteBatch(IReadOnlyList changes) { - Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); - Debug.Assert(content != null, "content must not be null"); + Debug.Assert(changes != null, "changes must not be null"); + + List preparedWrites = PrepareAll(changes); + CommitAll(preparedWrites); + } + + internal static async Task WriteBatchAsync(IReadOnlyList changes) + { + Debug.Assert(changes != null, "changes must not be null"); + + List preparedWrites = await PrepareAllAsync(changes); + // Commit is rename-only and fast; it must run without yields so no editor callback + // (asset refresh, domain reload) can observe a half-committed batch. + CommitAll(preparedWrites); + } + + private static List PrepareAll(IReadOnlyList changes) + { + List preparedWrites = new(changes.Count); + bool prepared = false; + try + { + foreach (MigrationFileChange change in changes) + { + Prepare(preparedWrites, change); + } + + prepared = true; + return preparedWrites; + } + finally + { + if (!prepared) + { + // A prepare failure has not touched any target file yet; deleting the temp + // sidecars returns the project to its exact pre-apply state. + DeleteTempSidecars(preparedWrites, 0); + } + } + } + + private static async Task> PrepareAllAsync( + IReadOnlyList changes) + { + List preparedWrites = new(changes.Count); + bool prepared = false; + try + { + for (int index = 0; index < changes.Count; index++) + { + Prepare(preparedWrites, changes[index]); + if ((index + 1) % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } - string tempFilePath = CreateUniqueSidecarPath(filePath, ".tmp"); - ThirdPartyToolMigrationFileAccess.WriteAllText(tempFilePath, content); - if (!ThirdPartyToolMigrationFileAccess.Exists(filePath)) + prepared = true; + return preparedWrites; + } + finally { - ThirdPartyToolMigrationFileAccess.Move(tempFilePath, filePath); - return; + if (!prepared) + { + DeleteTempSidecars(preparedWrites, 0); + } } + } + + private static void Prepare(List preparedWrites, MigrationFileChange change) + { + string tempFilePath = CreateUniqueSidecarPath(change.FilePath, TempSidecarExtension); + // Register before WriteAllText so a mid-write IOException can still clean up a partial .tmp. + preparedWrites.Add(new PreparedWrite(change.FilePath, tempFilePath)); + ThirdPartyToolMigrationFileAccess.WriteAllText(tempFilePath, change.Content); + } + + private static void CommitAll(List preparedWrites) + { + List committedWrites = new(preparedWrites.Count); + bool committed = false; + try + { + foreach (PreparedWrite preparedWrite in preparedWrites) + { + committedWrites.Add(Commit(preparedWrite)); + } - string backupFilePath = CreateUniqueSidecarPath(filePath, ".bak"); - ThirdPartyToolMigrationFileAccess.Replace(tempFilePath, filePath, backupFilePath); - ThirdPartyToolMigrationFileAccess.Delete(backupFilePath); + committed = true; + } + finally + { + if (committed) + { + DeleteBackupSidecars(committedWrites); + } + else + { + // The commit exception is propagating right now; rollback and cleanup must be + // best-effort so they never replace that original exception. + RollbackCommitted(committedWrites); + DeleteTempSidecars(preparedWrites, committedWrites.Count); + } + } + } + + private static CommittedWrite Commit(PreparedWrite preparedWrite) + { + if (!ThirdPartyToolMigrationFileAccess.Exists(preparedWrite.TargetFilePath)) + { + ThirdPartyToolMigrationFileAccess.Move( + preparedWrite.TempFilePath, + preparedWrite.TargetFilePath); + return new CommittedWrite(preparedWrite.TargetFilePath, null); + } + + string backupFilePath = CreateUniqueSidecarPath( + preparedWrite.TargetFilePath, + BackupSidecarExtension); + ThirdPartyToolMigrationFileAccess.Replace( + preparedWrite.TempFilePath, + preparedWrite.TargetFilePath, + backupFilePath); + return new CommittedWrite(preparedWrite.TargetFilePath, backupFilePath); + } + + private static void RollbackCommitted(List committedWrites) + { + for (int index = committedWrites.Count - 1; index >= 0; index--) + { + CommittedWrite committedWrite = committedWrites[index]; + try + { + ThirdPartyToolMigrationFileAccess.Delete(committedWrite.TargetFilePath); + if (committedWrite.BackupFilePath != null) + { + ThirdPartyToolMigrationFileAccess.Move( + committedWrite.BackupFilePath, + committedWrite.TargetFilePath); + } + } + catch (Exception restoreException) + { + // Keep restoring the remaining files; a file that cannot be restored keeps + // its .bak on disk so the user can recover it manually. + UnityEngine.Debug.LogError( + $"[uloop] Migration rollback failed for '{committedWrite.TargetFilePath}': " + + $"{restoreException.Message}" + + (committedWrite.BackupFilePath == null + ? string.Empty + : $" Backup kept at '{committedWrite.BackupFilePath}'.")); + } + } + } + + private static void DeleteTempSidecars(List preparedWrites, int firstIndex) + { + for (int index = firstIndex; index < preparedWrites.Count; index++) + { + TryDeleteSidecar(preparedWrites[index].TempFilePath); + } + } + + private static void DeleteBackupSidecars(List committedWrites) + { + foreach (CommittedWrite committedWrite in committedWrites) + { + if (committedWrite.BackupFilePath != null) + { + TryDeleteSidecar(committedWrite.BackupFilePath); + } + } + } + + private static void TryDeleteSidecar(string sidecarFilePath) + { + try + { + if (ThirdPartyToolMigrationFileAccess.Exists(sidecarFilePath)) + { + ThirdPartyToolMigrationFileAccess.Delete(sidecarFilePath); + } + } + catch (Exception deleteException) + { + // Sidecar cleanup must never fail the migration itself; a stray sidecar is + // visible in the project window and harmless to compilation. + UnityEngine.Debug.LogError( + $"[uloop] Failed to delete migration sidecar '{sidecarFilePath}': " + + $"{deleteException.Message}"); + } } internal static string CreateUniqueSidecarPath(string filePath, string extension) @@ -43,6 +226,31 @@ internal static string CreateUniqueSidecarPath(string filePath, string extension return sidecarPath; } + + private readonly struct PreparedWrite + { + public PreparedWrite(string targetFilePath, string tempFilePath) + { + TargetFilePath = targetFilePath; + TempFilePath = tempFilePath; + } + + public string TargetFilePath { get; } + public string TempFilePath { get; } + } + + private readonly struct CommittedWrite + { + public CommittedWrite(string targetFilePath, string backupFilePath) + { + TargetFilePath = targetFilePath; + BackupFilePath = backupFilePath; + } + + public string TargetFilePath { get; } + // Null when the target file did not exist before the commit (plain move, no backup). + public string BackupFilePath { get; } + } } /// From bbeaf9bf3182042f29f4a7451c7d828b94cbb792 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 10:25:55 +0900 Subject: [PATCH 2/9] fix: Preserve encoding and asmdef formatting during migration writes (#1702) Co-authored-by: Cursor --- ...PartyToolMigrationAsmdefTextEditorTests.cs | 177 +++++++++++ ...ToolMigrationAsmdefTextEditorTests.cs.meta | 11 + .../ThirdPartyToolMigrationFileWriterTests.cs | 113 +++++++ .../ThirdPartyToolMigrationAsmdefRules.cs | 20 +- ...ThirdPartyToolMigrationAsmdefTextEditor.cs | 291 ++++++++++++++++++ ...PartyToolMigrationAsmdefTextEditor.cs.meta | 11 + .../ThirdPartyToolMigrationFileWriter.cs | 63 +++- 7 files changed, 680 insertions(+), 6 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs create mode 100644 Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs new file mode 100644 index 000000000..c9ac97c08 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Text; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies surgical asmdef references edits preserve surrounding bytes. + /// + public sealed class ThirdPartyToolMigrationAsmdefTextEditorTests + { + [Test] + public void ReplaceReferencesArray_WhenSourceUsesCrlfAndFourSpaceIndent_PreservesBytesOutsideArray() + { + // Verifies CRLF/4-space asmdef text keeps every byte outside the references array. + string source = + "{\r\n" + + " \"name\": \"MyCompany.Tools.Editor\",\r\n" + + " \"references\": [\r\n" + + " \"uLoopMCP.Editor\"\r\n" + + " ],\r\n" + + " \"includePlatforms\": [\r\n" + + " \"Editor\"\r\n" + + " ]\r\n" + + "}\r\n"; + string expected = + "{\r\n" + + " \"name\": \"MyCompany.Tools.Editor\",\r\n" + + " \"references\": [\r\n" + + " \"GUID:fc3fd32eddbee40e39c2d76dc184957b\"\r\n" + + " ],\r\n" + + " \"includePlatforms\": [\r\n" + + " \"Editor\"\r\n" + + " ]\r\n" + + "}\r\n"; + + ThirdPartyToolMigrationAsmdefTextEditor.AsmdefReferencesEditResult result = + ThirdPartyToolMigrationAsmdefTextEditor.ReplaceReferencesArray( + source, + new[] { "GUID:fc3fd32eddbee40e39c2d76dc184957b" }); + + Assert.That(result.Replaced, Is.True); + Assert.That(result.Content, Is.EqualTo(expected)); + } + + [Test] + public void ReplaceReferencesArray_WhenSourceUsesTwoSpaceIndentAndLf_MatchesSourceStyle() + { + // Verifies 2-space LF asmdefs render migrated elements with matching indentation. + string source = + "{\n" + + " \"name\": \"MyCompany.Tools.Editor\",\n" + + " \"references\": [\n" + + " \"Old\"\n" + + " ]\n" + + "}\n"; + + ThirdPartyToolMigrationAsmdefTextEditor.AsmdefReferencesEditResult result = + ThirdPartyToolMigrationAsmdefTextEditor.ReplaceReferencesArray( + source, + new[] { "New1", "New2" }); + + Assert.That(result.Replaced, Is.True); + Assert.That(result.Content, Does.Contain(" \"references\": [\n \"New1\",\n \"New2\"\n ]")); + Assert.That(result.Content, Does.Not.Contain("\r\n")); + } + + [Test] + public void ReplaceReferencesArray_WhenArrayIsSingleLine_KeepsSingleLineFormat() + { + // Verifies a single-line references array stays single-line after replacement. + string source = "{\"name\":\"MyCompany.Tools.Editor\",\"references\": [\"Old\"]}"; + + ThirdPartyToolMigrationAsmdefTextEditor.AsmdefReferencesEditResult result = + ThirdPartyToolMigrationAsmdefTextEditor.ReplaceReferencesArray( + source, + new[] { "New1", "New2" }); + + Assert.That(result.Replaced, Is.True); + Assert.That(result.Content, Is.EqualTo( + "{\"name\":\"MyCompany.Tools.Editor\",\"references\": [\"New1\", \"New2\"]}")); + } + + [Test] + public void ReplaceReferencesArray_WhenStringValueEqualsReferences_IgnoresDecoyValue() + { + // Verifies decoy string values and escaped text do not steal the references array match. + string source = + "{\n" + + " \"name\": \"references\",\n" + + " \"note\": \"\\\"references\\\":\",\n" + + " \"references\": [\"Old\"]\n" + + "}\n"; + + ThirdPartyToolMigrationAsmdefTextEditor.AsmdefReferencesEditResult result = + ThirdPartyToolMigrationAsmdefTextEditor.ReplaceReferencesArray( + source, + new[] { "New" }); + + Assert.That(result.Replaced, Is.True); + Assert.That(result.Content, Does.Contain("\"name\": \"references\"")); + Assert.That(result.Content, Does.Contain("\"note\": \"\\\"references\\\":\"")); + Assert.That(result.Content, Does.Contain("\"references\": [\"New\"]")); + } + + [Test] + public void ReplaceReferencesArray_WhenReferencesPropertyIsMissing_ReturnsNotReplaced() + { + // Verifies asmdefs without a references property report NotReplaced. + string source = + "{\n" + + " \"name\": \"MyCompany.Tools.Editor\"\n" + + "}\n"; + + ThirdPartyToolMigrationAsmdefTextEditor.AsmdefReferencesEditResult result = + ThirdPartyToolMigrationAsmdefTextEditor.ReplaceReferencesArray( + source, + new[] { "New" }); + + Assert.That(result.Replaced, Is.False); + Assert.That(result.Content, Is.EqualTo(string.Empty)); + } + + [Test] + public void MigrateAsmdefSource_WhenReferencesPropertyIsMissing_FallsBackToReserialization() + { + // Verifies missing references still gains required entries through full re-serialization. + string source = + "{\n" + + " \"name\": \"MyCompany.Tools.Editor\"\n" + + "}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: true, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: false, + requiresFirstPartyScreenshotReference: 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_WhenNothingChanges_ReturnsSourceUnchanged() + { + // Verifies the early-return path leaves the original asmdef text untouched. + string source = + "{\r\n" + + " \"name\": \"MyCompany.Tools.Editor\",\r\n" + + " \"references\": [\r\n" + + " \"GUID:fc3fd32eddbee40e39c2d76dc184957b\"\r\n" + + " ]\r\n" + + "}\r\n"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: false, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: false, + requiresFirstPartyScreenshotReference: false); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + Assert.That(Encoding.UTF8.GetBytes(result.Content), Is.EqualTo(Encoding.UTF8.GetBytes(source))); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs.meta new file mode 100644 index 000000000..914ff22fd --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationAsmdefTextEditorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e62e3d40a673d4e338e77cde3e7e3aa6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs index 4743f6424..ab82be3df 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileWriterTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using NUnit.Framework; @@ -177,6 +178,118 @@ public async Task WriteBatchAsync_WhenBatchExceedsYieldSize_CommitsEveryFile() } } + [Test] + public void WriteBatch_WhenTargetHasUtf8Bom_PreservesBomBytes() + { + // Verifies a UTF-8 BOM target keeps EF BB BF after a batch write. + string tempDirectory = CreateTempDirectory(); + try + { + string filePath = Path.Combine(tempDirectory, "BomFile.cs"); + File.WriteAllText(filePath, "original", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + + ThirdPartyToolMigrationFileWriter.WriteBatch( + new List + { + new MigrationFileChange(filePath, "migrated") + }); + + byte[] bytes = File.ReadAllBytes(filePath); + Assert.That(bytes[0], Is.EqualTo(0xEF)); + Assert.That(bytes[1], Is.EqualTo(0xBB)); + Assert.That(bytes[2], Is.EqualTo(0xBF)); + Assert.That(Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3), Is.EqualTo("migrated")); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + public void WriteBatch_WhenTargetIsUtf16LittleEndian_PreservesEncoding() + { + // Verifies a UTF-16 LE BOM target is rewritten as UTF-16 LE with the new content. + string tempDirectory = CreateTempDirectory(); + try + { + string filePath = Path.Combine(tempDirectory, "Utf16File.cs"); + Encoding utf16 = new UnicodeEncoding(bigEndian: false, byteOrderMark: true); + File.WriteAllText(filePath, "original", utf16); + + ThirdPartyToolMigrationFileWriter.WriteBatch( + new List + { + new MigrationFileChange(filePath, "migrated") + }); + + byte[] bytes = File.ReadAllBytes(filePath); + Assert.That(bytes[0], Is.EqualTo(0xFF)); + Assert.That(bytes[1], Is.EqualTo(0xFE)); + Assert.That(utf16.GetString(bytes, 2, bytes.Length - 2), Is.EqualTo("migrated")); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + public void WriteBatch_WhenTargetHasNoBom_DoesNotAddBom() + { + // Verifies a BOM-less UTF-8 target does not gain EF BB BF after a batch write. + string tempDirectory = CreateTempDirectory(); + try + { + string filePath = Path.Combine(tempDirectory, "NoBomFile.cs"); + File.WriteAllText(filePath, "original", new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + ThirdPartyToolMigrationFileWriter.WriteBatch( + new List + { + new MigrationFileChange(filePath, "migrated") + }); + + byte[] bytes = File.ReadAllBytes(filePath); + Assert.That(bytes.Length, Is.GreaterThanOrEqualTo(1)); + Assert.That(bytes[0], Is.Not.EqualTo(0xEF)); + Assert.That(Encoding.UTF8.GetString(bytes), Is.EqualTo("migrated")); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Test] + public void WriteBatch_WhenContentUsesCrlf_PreservesCrlfBytes() + { + // Verifies CRLF content bytes survive the batch write without lone LF rewriting. + string tempDirectory = CreateTempDirectory(); + try + { + string filePath = Path.Combine(tempDirectory, "CrlfFile.cs"); + File.WriteAllText(filePath, "original\r\nline", new UTF8Encoding(false)); + string migratedContent = "migrated\r\nline\r\n"; + + ThirdPartyToolMigrationFileWriter.WriteBatch( + new List + { + new MigrationFileChange(filePath, migratedContent) + }); + + byte[] bytes = File.ReadAllBytes(filePath); + string decoded = Encoding.UTF8.GetString(bytes); + Assert.That(decoded, Is.EqualTo(migratedContent)); + Assert.That(decoded.Contains("\r\n"), Is.True); + Assert.That(decoded.Replace("\r\n", string.Empty).Contains("\n"), Is.False); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static string CreateTempDirectory() { string tempDirectory = Path.Combine( diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefRules.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefRules.cs index 5e526acf4..83142431c 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefRules.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefRules.cs @@ -51,14 +51,28 @@ internal static ThirdPartyToolMigrationContentResult MigrateAsmdefSource( Array.Empty()); } - JArray migratedReferences = CreateReferencesArray(migrationResult.References); - asmdef["references"] = migratedReferences; + ThirdPartyToolMigrationAsmdefTextEditor.AsmdefReferencesEditResult editResult = + ThirdPartyToolMigrationAsmdefTextEditor.ReplaceReferencesArray(source, migrationResult.References); + // The surgical edit needs an existing "references" array to anchor on; when the asmdef has + // none, there is no original formatting to preserve, so full re-serialization is acceptable. + string migratedSource = editResult.Replaced + ? editResult.Content + : CreateReserializedAsmdefSource(asmdef, migrationResult.References); return new ThirdPartyToolMigrationContentResult( - asmdef.ToString(Formatting.Indented), + migratedSource, migrationResult.ReplacementCount, Array.Empty()); } + private static string CreateReserializedAsmdefSource(JObject asmdef, string[] references) + { + Debug.Assert(asmdef != null, "asmdef must not be null"); + Debug.Assert(references != null, "references must not be null"); + + asmdef["references"] = CreateReferencesArray(references); + return asmdef.ToString(Formatting.Indented); + } + private static string[] ReadReferenceValues(JArray references) { Debug.Assert(references != null, "references must not be null"); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs new file mode 100644 index 000000000..943fcc1c1 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs @@ -0,0 +1,291 @@ +using System; +using System.Diagnostics; +using System.Text; + +using Newtonsoft.Json; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Replaces only the top-level "references" array inside asmdef JSON text so every byte + /// outside that array (indentation, line endings, trailing newline, other properties) + /// is preserved exactly. + /// + internal static class ThirdPartyToolMigrationAsmdefTextEditor + { + internal static AsmdefReferencesEditResult ReplaceReferencesArray( + string source, + string[] references) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(references != null, "references must not be null"); + Debug.Assert(references.Length > 0, "references must not be empty"); + + ReferencesArraySpan span = FindTopLevelReferencesArray(source); + if (!span.Found) + { + return AsmdefReferencesEditResult.NotReplaced; + } + + string renderedArray = RenderReferencesArray(source, span, references); + string content = source.Substring(0, span.ArrayStartIndex) + + renderedArray + + source.Substring(span.ArrayEndIndexExclusive); + return AsmdefReferencesEditResult.ReplacedWith(content); + } + + private static ReferencesArraySpan FindTopLevelReferencesArray(string source) + { + int depth = 0; + int index = 0; + while (index < source.Length) + { + char current = source[index]; + if (current == '"') + { + int stringStartIndex = index; + index = SkipJsonString(source, index); + if (depth != 1) + { + continue; + } + + // A depth-1 string is a property name only when the next + // non-whitespace character is a colon. + int colonIndex = SkipWhitespace(source, index); + if (colonIndex >= source.Length || source[colonIndex] != ':') + { + continue; + } + + string propertyName = source.Substring( + stringStartIndex + 1, + index - stringStartIndex - 2); + int valueStartIndex = SkipWhitespace(source, colonIndex + 1); + if (!string.Equals(propertyName, "references", StringComparison.Ordinal) || + valueStartIndex >= source.Length || + source[valueStartIndex] != '[') + { + continue; + } + + int arrayEndIndexExclusive = SkipJsonArray(source, valueStartIndex); + return ReferencesArraySpan.FoundAt( + stringStartIndex, + valueStartIndex, + arrayEndIndexExclusive); + } + + if (current == '{' || current == '[') + { + depth++; + } + else if (current == '}' || current == ']') + { + depth--; + } + + index++; + } + + return ReferencesArraySpan.NotFound; + } + + // Returns the index just after the closing quote. + private static int SkipJsonString(string source, int openQuoteIndex) + { + int index = openQuoteIndex + 1; + while (index < source.Length) + { + char current = source[index]; + if (current == '\\') + { + index += 2; + continue; + } + + if (current == '"') + { + return index + 1; + } + + index++; + } + + return source.Length; + } + + // Returns the index just after the matching closing bracket. + private static int SkipJsonArray(string source, int openBracketIndex) + { + int bracketDepth = 0; + int index = openBracketIndex; + while (index < source.Length) + { + char current = source[index]; + if (current == '"') + { + index = SkipJsonString(source, index); + continue; + } + + if (current == '[') + { + bracketDepth++; + } + else if (current == ']') + { + bracketDepth--; + if (bracketDepth == 0) + { + return index + 1; + } + } + + index++; + } + + return source.Length; + } + + private static int SkipWhitespace(string source, int startIndex) + { + int index = startIndex; + while (index < source.Length && char.IsWhiteSpace(source[index])) + { + index++; + } + + return index; + } + + private static string RenderReferencesArray( + string source, + ReferencesArraySpan span, + string[] references) + { + string originalArrayText = source.Substring( + span.ArrayStartIndex, + span.ArrayEndIndexExclusive - span.ArrayStartIndex); + if (originalArrayText.IndexOf('\n') < 0) + { + return RenderSingleLineArray(references); + } + + // The file's own newline flavor and the property line's indent decide the layout; + // Environment.NewLine would silently rewrite CRLF files on macOS and vice versa. + string newline = source.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; + string propertyIndent = GetLineIndent(source, span.PropertyNameIndex); + string indentUnit = propertyIndent.Length > 0 ? propertyIndent : " "; + string elementIndent = propertyIndent + indentUnit; + StringBuilder builder = new(); + builder.Append('['); + for (int index = 0; index < references.Length; index++) + { + builder.Append(newline); + builder.Append(elementIndent); + builder.Append(JsonConvert.ToString(references[index])); + if (index < references.Length - 1) + { + builder.Append(','); + } + } + + builder.Append(newline); + builder.Append(propertyIndent); + builder.Append(']'); + return builder.ToString(); + } + + private static string RenderSingleLineArray(string[] references) + { + StringBuilder builder = new(); + builder.Append('['); + for (int index = 0; index < references.Length; index++) + { + builder.Append(JsonConvert.ToString(references[index])); + if (index < references.Length - 1) + { + builder.Append(", "); + } + } + + builder.Append(']'); + return builder.ToString(); + } + + // Returns the leading whitespace of the property's line, or empty when the + // property does not start its own line. + private static string GetLineIndent(string source, int propertyNameIndex) + { + Debug.Assert(propertyNameIndex > 0, "propertyNameIndex must be inside the object"); + + int lineStartIndex = source.LastIndexOf('\n', propertyNameIndex - 1) + 1; + int index = lineStartIndex; + while (index < propertyNameIndex && (source[index] == ' ' || source[index] == '\t')) + { + index++; + } + + if (index != propertyNameIndex) + { + return string.Empty; + } + + return source.Substring(lineStartIndex, index - lineStartIndex); + } + + internal readonly struct AsmdefReferencesEditResult + { + public static AsmdefReferencesEditResult NotReplaced => new(false, string.Empty); + + public static AsmdefReferencesEditResult ReplacedWith(string content) + { + return new AsmdefReferencesEditResult(true, content); + } + + private AsmdefReferencesEditResult(bool replaced, string content) + { + Replaced = replaced; + Content = content; + } + + public bool Replaced { get; } + public string Content { get; } + } + + private readonly struct ReferencesArraySpan + { + public static ReferencesArraySpan NotFound => new(false, 0, 0, 0); + + public static ReferencesArraySpan FoundAt( + int propertyNameIndex, + int arrayStartIndex, + int arrayEndIndexExclusive) + { + return new ReferencesArraySpan( + true, + propertyNameIndex, + arrayStartIndex, + arrayEndIndexExclusive); + } + + private ReferencesArraySpan( + bool found, + int propertyNameIndex, + int arrayStartIndex, + int arrayEndIndexExclusive) + { + Found = found; + PropertyNameIndex = propertyNameIndex; + ArrayStartIndex = arrayStartIndex; + ArrayEndIndexExclusive = arrayEndIndexExclusive; + } + + public bool Found { get; } + public int PropertyNameIndex { get; } + public int ArrayStartIndex { get; } + public int ArrayEndIndexExclusive { get; } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs.meta new file mode 100644 index 000000000..2bd7a40f5 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefTextEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0cca2e10f7002407c963bc4f3966b7e8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs index f121d7f3f..d6f9e92b7 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Text; using System.Threading.Tasks; namespace io.github.hatayama.UnityCliLoop.Infrastructure @@ -92,7 +93,12 @@ private static void Prepare(List preparedWrites, MigrationFileCha string tempFilePath = CreateUniqueSidecarPath(change.FilePath, TempSidecarExtension); // Register before WriteAllText so a mid-write IOException can still clean up a partial .tmp. preparedWrites.Add(new PreparedWrite(change.FilePath, tempFilePath)); - ThirdPartyToolMigrationFileAccess.WriteAllText(tempFilePath, change.Content); + // The plan carries decoded strings only, so the original file's BOM/encoding must be + // re-detected here and reapplied; otherwise every migrated file collapses to BOM-less UTF-8. + Encoding encoding = ThirdPartyToolMigrationFileAccess.Exists(change.FilePath) + ? ThirdPartyToolMigrationFileAccess.DetectEncodingFromBom(change.FilePath) + : new UTF8Encoding(false); + ThirdPartyToolMigrationFileAccess.WriteAllTextWithEncoding(tempFilePath, change.Content, encoding); } private static void CommitAll(List preparedWrites) @@ -265,12 +271,63 @@ internal static string ReadAllText(string filePath) return File.ReadAllText(GetFileSystemPath(filePath)); } - internal static void WriteAllText(string filePath, string content) + internal static Encoding DetectEncodingFromBom(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + byte[] bom = new byte[4]; + int readCount = 0; + using (FileStream stream = File.OpenRead(GetFileSystemPath(filePath))) + { + // FileStream.Read may return fewer bytes than requested; read until EOF or 4 bytes. + while (readCount < bom.Length) + { + int read = stream.Read(bom, readCount, bom.Length - readCount); + if (read == 0) + { + break; + } + + readCount += read; + } + } + + // UTF-32 BOMs must be checked before UTF-16 because they share the same leading bytes. + if (readCount >= 4 && bom[0] == 0xFF && bom[1] == 0xFE && bom[2] == 0x00 && bom[3] == 0x00) + { + return new UTF32Encoding(bigEndian: false, byteOrderMark: true); + } + + if (readCount >= 4 && bom[0] == 0x00 && bom[1] == 0x00 && bom[2] == 0xFE && bom[3] == 0xFF) + { + return new UTF32Encoding(bigEndian: true, byteOrderMark: true); + } + + if (readCount >= 3 && bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF) + { + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + } + + if (readCount >= 2 && bom[0] == 0xFF && bom[1] == 0xFE) + { + return new UnicodeEncoding(bigEndian: false, byteOrderMark: true); + } + + if (readCount >= 2 && bom[0] == 0xFE && bom[1] == 0xFF) + { + return new UnicodeEncoding(bigEndian: true, byteOrderMark: true); + } + + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + } + + internal static void WriteAllTextWithEncoding(string filePath, string content, Encoding encoding) { Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); Debug.Assert(content != null, "content must not be null"); + Debug.Assert(encoding != null, "encoding must not be null"); - File.WriteAllText(GetFileSystemPath(filePath), content); + File.WriteAllText(GetFileSystemPath(filePath), content, encoding); } internal static IEnumerable ReadLines(string filePath) From ddd600411f73a5819822677765511be090d87ce5 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 10:31:48 +0900 Subject: [PATCH 3/9] fix: Stop migration masking from treating directive apostrophes as chars (#1703) Co-authored-by: Cursor --- ...tyToolMigrationCodeTextMaskBuilderTests.cs | 78 +++++++++++++++++++ ...lMigrationCodeTextMaskBuilderTests.cs.meta | 11 +++ ...rdPartyToolMigrationCodeTextMaskBuilder.cs | 42 +++++++++- .../ThirdPartyToolMigrationRuleCatalog.cs | 2 + 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs new file mode 100644 index 000000000..be4772093 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs @@ -0,0 +1,78 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies migration code masking ignores preprocessor text and bounds char literals. + /// + public sealed class ThirdPartyToolMigrationCodeTextMaskBuilderTests + { + [Test] + public void CreateCodeCharacters_WhenRegionNameContainsApostrophe_KeepsFollowingCodeUnmasked() + { + // Verifies a #region title apostrophe does not mask the rest of the file as a char literal. + string source = "#region Bob's helpers\npublic class Tool {}\n"; + + bool[] codeCharacters = ThirdPartyToolMigrationCodeTextMaskBuilder.CreateCodeCharacters(source); + + int codeStartIndex = source.IndexOf("public class Tool"); + Assert.That(codeStartIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(AreAllCodeCharacters(codeCharacters, codeStartIndex, "public class Tool".Length), Is.True); + } + + [Test] + public void CreateCodeCharacters_WhenWarningContainsApostrophe_KeepsFollowingCodeUnmasked() + { + // Verifies a #warning apostrophe does not mask the following source as a char literal. + string source = "#warning Don't call this API\npublic class Tool {}\n"; + + bool[] codeCharacters = ThirdPartyToolMigrationCodeTextMaskBuilder.CreateCodeCharacters(source); + + int codeStartIndex = source.IndexOf("public class Tool"); + Assert.That(codeStartIndex, Is.GreaterThanOrEqualTo(0)); + Assert.That(AreAllCodeCharacters(codeCharacters, codeStartIndex, "public class Tool".Length), Is.True); + } + + [Test] + public void CreateCodeCharacters_WhenValidCharLiteral_MasksOnlyTheLiteral() + { + // Verifies a real char literal stays masked while the following statement remains code. + string source = "char c = 'a';\nint x = 1;\n"; + + bool[] codeCharacters = ThirdPartyToolMigrationCodeTextMaskBuilder.CreateCodeCharacters(source); + + int literalIndex = source.IndexOf("'a'"); + Assert.That(codeCharacters[literalIndex], Is.False); + Assert.That(codeCharacters[literalIndex + 1], Is.False); + Assert.That(codeCharacters[literalIndex + 2], Is.False); + Assert.That(codeCharacters[source.IndexOf("int x")], Is.True); + } + + [Test] + public void CreateCodeCharacters_WhenApostropheDoesNotCloseSoon_TreatsItAsCode() + { + // Verifies a long unmatched apostrophe is not treated as an open-ended char literal. + string source = "var name = Bob's helpers;\nint x = 1;\n"; + + bool[] codeCharacters = ThirdPartyToolMigrationCodeTextMaskBuilder.CreateCodeCharacters(source); + + Assert.That(codeCharacters[source.IndexOf("int x")], Is.True); + Assert.That(AreAllCodeCharacters(codeCharacters, source.IndexOf("helpers"), "helpers".Length), Is.True); + } + + private static bool AreAllCodeCharacters(bool[] codeCharacters, int startIndex, int length) + { + for (int index = startIndex; index < startIndex + length; index++) + { + if (!codeCharacters[index]) + { + return false; + } + } + + return true; + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs.meta new file mode 100644 index 000000000..6b3541205 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationCodeTextMaskBuilderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca68b733dc7204b64827610344755ef4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationCodeTextMaskBuilder.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationCodeTextMaskBuilder.cs index e4dcb8166..67765e7e5 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationCodeTextMaskBuilder.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationCodeTextMaskBuilder.cs @@ -62,6 +62,16 @@ public static bool[] CreateCodeCharacters(string source) int index = 0; while (index < source.Length) { + if (IsPreprocessorDirectiveAt(source, index)) + { + // Preprocessor text is not C# code; skipping the whole directive line prevents + // apostrophes in titles like #region Bob's helpers from opening a char literal. + int directiveEndIndex = FindLineCommentEndIndex(source, index); + MarkRangeAsIgnored(codeCharacters, index, directiveEndIndex); + index = directiveEndIndex; + continue; + } + if (IsInterpolatedRawStringStart(source, index)) { index = MarkInterpolatedRawStringAsIgnored(codeCharacters, source, index); @@ -244,7 +254,10 @@ public static int FindRawStringEndIndex(string source, int quoteIndex) public static int FindCharLiteralEndIndex(string source, int quoteIndex) { int index = quoteIndex + 1; - while (index < source.Length) + // Longest valid C# char literal content is \Uxxxxxxxx (10 chars). An unmatched apostrophe + // beyond that bound is ordinary code, not an open-ended char literal that masks the file. + int maxClosingQuoteIndex = quoteIndex + 1 + MaxCharLiteralContentLength; + while (index <= maxClosingQuoteIndex && index < source.Length) { if (source[index] == '\\') { @@ -260,7 +273,32 @@ public static int FindCharLiteralEndIndex(string source, int quoteIndex) index++; } - return source.Length; + return quoteIndex; + } + + public static bool IsPreprocessorDirectiveAt(string source, int index) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(index >= 0, "index must not be negative"); + + if (index >= source.Length || source[index] != '#') + { + return false; + } + + int lineStartIndex = index; + while (lineStartIndex > 0 && source[lineStartIndex - 1] != '\n') + { + lineStartIndex--; + } + + int cursor = lineStartIndex; + while (cursor < index && (source[cursor] == ' ' || source[cursor] == '\t')) + { + cursor++; + } + + return cursor == index; } public static void MarkRangeAsIgnored(bool[] codeCharacters, int startIndex, int endIndex) diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationRuleCatalog.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationRuleCatalog.cs index 2f80b4047..632be3cff 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationRuleCatalog.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationRuleCatalog.cs @@ -56,6 +56,8 @@ public static class ThirdPartyToolMigrationRuleCatalog public const string CurrentConstantsTypeName = "UnityCliLoopConstants"; public const string CurrentEditorFrameWaitTimeoutMemberName = "EDITOR_FRAME_WAIT_TIMEOUT_MS"; public const int MinimumRawStringDelimiterQuoteCount = 3; + // Longest valid C# char literal content is \Uxxxxxxxx; beyond that an apostrophe is ordinary code. + public const int MaxCharLiteralContentLength = 10; public static readonly string[] ExcludedDirectoryNames = { ".git", From eea82b05a5fc342c62fbd3e11ca5fe3d1f9ce04e Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 10:36:56 +0900 Subject: [PATCH 4/9] fix: Guard contract type renames against local type shadowing (#1704) Co-authored-by: Cursor --- ...rtyToolMigrationLocalTypeShadowingTests.cs | 135 ++++++++++++++++++ ...olMigrationLocalTypeShadowingTests.cs.meta | 11 ++ .../ThirdPartyToolMigrationCSharpRules.cs | 3 + ...dPartyToolMigrationTypeReplacementRules.cs | 27 +++- 4 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs new file mode 100644 index 000000000..f9488f413 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs @@ -0,0 +1,135 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies local type shadowing is not rewritten by contract/domain/toolinfo renames. + /// + public sealed class ThirdPartyToolMigrationLocalTypeShadowingTests + { + [Test] + public void MigrateCSharpSource_WhenProjectDefinesSecuritySettings_KeepsProjectTypeUsages() + { + // Verifies a project-owned SecuritySettings type is not rewritten to the V3 security setting type. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class SecuritySettings +{ + public bool Enabled { get; set; } +} + +public sealed class SettingsFactory +{ + public SecuritySettings Create() + { + return new SecuritySettings(); + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Content, Does.Contain("public sealed class SecuritySettings")); + Assert.That(result.Content, Does.Contain("public SecuritySettings Create()")); + Assert.That(result.Content, Does.Contain("return new SecuritySettings();")); + Assert.That(result.Content, Does.Not.Contain("UnityCliLoopSecuritySetting")); + } + + [Test] + public void MigrateCSharpSource_WhenProjectDefinesServiceResult_KeepsProjectTypeUsages() + { + // Verifies a project-owned ServiceResult type is not rewritten to the ToolContracts type. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class ServiceResult +{ + public bool Ok { get; set; } +} + +public sealed class ResultFactory +{ + public ServiceResult Create() + { + return new ServiceResult(); + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Content, Does.Contain("public sealed class ServiceResult")); + Assert.That(result.Content, Does.Contain("public ServiceResult Create()")); + Assert.That(result.Content, Does.Contain("return new ServiceResult();")); + Assert.That( + result.Content, + Does.Not.Contain("io.github.hatayama.UnityCliLoop.ToolContracts.ServiceResult")); + } + + [Test] + public void MigrateCSharpSource_WhenProjectDefinesToolInfo_KeepsProjectTypeUsages() + { + // Verifies a project-owned ToolInfo type is not rewritten to the ToolContracts type. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class ToolInfo +{ + public string Name { get; set; } +} + +public sealed class ToolInfoFactory +{ + public ToolInfo Create() + { + return new ToolInfo(); + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Content, Does.Contain("public sealed class ToolInfo")); + Assert.That(result.Content, Does.Contain("public ToolInfo Create()")); + Assert.That(result.Content, Does.Contain("return new ToolInfo();")); + Assert.That( + result.Content, + Does.Not.Contain("io.github.hatayama.UnityCliLoop.ToolContracts.ToolInfo")); + } + + [Test] + public void MigrateCSharpSourceForLegacyAssembly_WhenAssemblyDeclaresSecuritySettings_KeepsProjectTypeUsages() + { + // Verifies sibling-file ownership of SecuritySettings also blocks bare reference rewrites. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class SettingsFactory +{ + public SecuritySettings Create() + { + return new SecuritySettings(); + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly( + source, + hasLegacyAssemblySource: true, + hasAssemblyScopedCurrentToolContractsUsing: false, + hasAssemblyScopedCurrentApplicationUsing: false, + hasAssemblyScopedCurrentDomainUsing: false, + hasAssemblyScopedCurrentFirstPartyToolsUsing: false, + legacyAssemblyAliases: System.Array.Empty(), + legacyAssemblyToolInfoAliases: System.Array.Empty(), + currentApplicationAssemblyAliases: System.Array.Empty(), + currentDomainAssemblyAliases: System.Array.Empty(), + currentFirstPartyToolsAssemblyAliases: System.Array.Empty(), + assemblyDeclaredTypeNames: new[] { "SecuritySettings" }); + + Assert.That(result.Content, Does.Contain("public SecuritySettings Create()")); + Assert.That(result.Content, Does.Contain("return new SecuritySettings();")); + Assert.That(result.Content, Does.Not.Contain("UnityCliLoopSecuritySetting")); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs.meta new file mode 100644 index 000000000..93dbeadf7 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationLocalTypeShadowingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8b72c13a78b849ab9ab7388b1658646 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationCSharpRules.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationCSharpRules.cs index 2c5f33778..2d3ec58e4 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationCSharpRules.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationCSharpRules.cs @@ -371,10 +371,12 @@ public void ApplyContractRenames() _migratedContent = ReplaceLegacyDomainTypeNamesInCode( _migratedContent, _legacyNamespaceAliases, + _assemblyDeclaredTypeNames, ref _replacementCount); _migratedContent = ReplaceLegacyContractTypeNamesInCode( _migratedContent, _legacyNamespaceAliases, + _assemblyDeclaredTypeNames, ref _replacementCount); ApplyCSharpReplacementRules(); } @@ -396,6 +398,7 @@ public void ApplyRegistrarRenames() ApplyRegistrarReplacementRules(); _migratedContent = ReplaceLegacyToolInfoTypeReferencesInCode( _migratedContent, + _assemblyDeclaredTypeNames, ref _replacementCount); } diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationTypeReplacementRules.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationTypeReplacementRules.cs index 1aa07fb40..97533cace 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationTypeReplacementRules.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationTypeReplacementRules.cs @@ -110,14 +110,19 @@ public static string ReplaceUnqualifiedLegacyRegistrarReferencesInCode( public static string ReplaceLegacyContractTypeNamesInCode( string source, string[] aliases, + string[] assemblyDeclaredTypeNames, ref int replacementCount) { Debug.Assert(source != null, "source must not be null"); Debug.Assert(aliases != null, "aliases must not be null"); + Debug.Assert(assemblyDeclaredTypeNames != null, "assemblyDeclaredTypeNames must not be null"); string migratedContent = source; foreach (TypeReplacementRule rule in ToolContractTypeReplacementRules) { + bool hasProtectedTypeDeclaration = DeclaresLocalType(migratedContent, rule.LegacyName) || + assemblyDeclaredTypeNames.Contains(rule.LegacyName); + Regex fullyQualifiedRegex = new( $@"(?:(?:global::)?{Regex.Escape(LegacyNamespace)}\.){Regex.Escape(rule.LegacyName)}\b", RegexOptions.Compiled); @@ -145,7 +150,8 @@ public static string ReplaceLegacyContractTypeNamesInCode( migratedContent = ReplaceRegexInCode( migratedContent, unqualifiedRegex, - match => ShouldMigrateLegacyTypeReference(migratedContent, rule.LegacyName, match.Index) + match => !hasProtectedTypeDeclaration && + ShouldMigrateLegacyTypeReference(migratedContent, rule.LegacyName, match.Index) ? rule.CurrentName : match.Value, ref replacementCount); @@ -157,14 +163,19 @@ public static string ReplaceLegacyContractTypeNamesInCode( public static string ReplaceLegacyDomainTypeNamesInCode( string source, string[] aliases, + string[] assemblyDeclaredTypeNames, ref int replacementCount) { Debug.Assert(source != null, "source must not be null"); Debug.Assert(aliases != null, "aliases must not be null"); + Debug.Assert(assemblyDeclaredTypeNames != null, "assemblyDeclaredTypeNames must not be null"); string migratedContent = source; foreach (TypeReplacementRule rule in DomainTypeReplacementRules) { + bool hasProtectedTypeDeclaration = DeclaresLocalType(migratedContent, rule.LegacyName) || + assemblyDeclaredTypeNames.Contains(rule.LegacyName); + Regex fullyQualifiedRegex = new( $@"(?:(?:global::)?{Regex.Escape(LegacyNamespace)}\.){Regex.Escape(rule.LegacyName)}\b", RegexOptions.Compiled); @@ -192,7 +203,8 @@ public static string ReplaceLegacyDomainTypeNamesInCode( migratedContent = ReplaceRegexInCode( migratedContent, unqualifiedRegex, - match => ShouldMigrateLegacyTypeReference(migratedContent, rule.LegacyName, match.Index) + match => !hasProtectedTypeDeclaration && + ShouldMigrateLegacyTypeReference(migratedContent, rule.LegacyName, match.Index) ? $"{CurrentNamespace}.{rule.CurrentName}" : match.Value, ref replacementCount); @@ -477,14 +489,21 @@ public static string ReplaceCurrentDomainContractTypeNameInCode( ref replacementCount); } - public static string ReplaceLegacyToolInfoTypeReferencesInCode(string source, ref int replacementCount) + public static string ReplaceLegacyToolInfoTypeReferencesInCode( + string source, + string[] assemblyDeclaredTypeNames, + ref int replacementCount) { Debug.Assert(source != null, "source must not be null"); + Debug.Assert(assemblyDeclaredTypeNames != null, "assemblyDeclaredTypeNames must not be null"); + bool hasProtectedTypeDeclaration = DeclaresLocalType(source, "ToolInfo") || + assemblyDeclaredTypeNames.Contains("ToolInfo"); return ReplaceRegexInCode( source, UnqualifiedToolInfoRegex, - match => ShouldMigrateLegacyToolInfoTypeReference(source, match.Index) + match => !hasProtectedTypeDeclaration && + ShouldMigrateLegacyToolInfoTypeReference(source, match.Index) ? $"{CurrentNamespace}.ToolInfo" : match.Value, ref replacementCount); From 1445b93bc9467cdf1e7c64198a744f88edfcc0a0 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 10:43:17 +0900 Subject: [PATCH 5/9] fix: Make migration wizard auto-scan and async handlers reliable (#1705) Co-authored-by: Cursor --- ...hirdPartyToolMigrationWizardWindowTests.cs | 83 +++++++++-- ...ThirdPartyToolMigrationWizardStateRules.cs | 7 - .../ThirdPartyToolMigrationWizardWindow.cs | 131 ++++++++++++++---- 3 files changed, 174 insertions(+), 47 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index 4729e438f..bb899bd31 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -16,21 +16,78 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor /// public sealed class ThirdPartyToolMigrationWizardWindowTests { - [TestCase(false, true, false)] - [TestCase(true, false, false)] - [TestCase(true, true, true)] - public void ShouldStartInitialRefresh_ReturnsExpectedValue( - bool shouldRefreshAfterCreateGui, - bool shouldAutoScanThirdPartyToolMigration, - bool expected) + [Test] + public void ConsumeShouldStartInitialRefresh_WhenPreparedForAutoScan_ReturnsTrueWithoutSessionFlag() { - // Verifies that automatic scanning requires both auto-open intent and the session scan flag. - bool shouldStartInitialRefresh = - ThirdPartyToolMigrationWizardWindow.ShouldStartInitialRefresh( - shouldRefreshAfterCreateGui, - shouldAutoScanThirdPartyToolMigration); + // Verifies auto-open windows start scanning from the serialized refresh flag alone. + ThirdPartyToolMigrationWizardWindow window = + ScriptableObject.CreateInstance(); + try + { + ThirdPartyToolMigrationWizardWindow.PrepareForOpen( + window, + "Unity CLI Loop Migration", + new Rect(12f, 34f, 360f, 220f), + shouldRefreshAfterCreateGui: true); + + bool shouldStartInitialRefresh = window.ConsumeShouldStartInitialRefresh(); + + Assert.That(shouldStartInitialRefresh, Is.True); + } + finally + { + Object.DestroyImmediate(window); + } + } - Assert.That(shouldStartInitialRefresh, Is.EqualTo(expected)); + [Test] + public void ConsumeShouldStartInitialRefresh_WhenConsumedTwice_ReturnsFalseOnSecondCall() + { + // Verifies the initial-refresh flag is one-shot so CreateGUI cannot double-scan. + ThirdPartyToolMigrationWizardWindow window = + ScriptableObject.CreateInstance(); + try + { + ThirdPartyToolMigrationWizardWindow.PrepareForOpen( + window, + "Unity CLI Loop Migration", + new Rect(12f, 34f, 360f, 220f), + shouldRefreshAfterCreateGui: true); + + bool first = window.ConsumeShouldStartInitialRefresh(); + bool second = window.ConsumeShouldStartInitialRefresh(); + + Assert.That(first, Is.True); + Assert.That(second, Is.False); + } + finally + { + Object.DestroyImmediate(window); + } + } + + [Test] + public void ConsumeShouldStartInitialRefresh_WhenManualOpen_ReturnsFalse() + { + // Verifies manually opened windows wait for an explicit Check instead of auto-scanning. + ThirdPartyToolMigrationWizardWindow window = + ScriptableObject.CreateInstance(); + try + { + ThirdPartyToolMigrationWizardWindow.PrepareForOpen( + window, + "Unity CLI Loop Migration", + new Rect(12f, 34f, 360f, 220f), + shouldRefreshAfterCreateGui: false); + + bool shouldStartInitialRefresh = window.ConsumeShouldStartInitialRefresh(); + + Assert.That(shouldStartInitialRefresh, Is.False); + } + finally + { + Object.DestroyImmediate(window); + } } [TestCase(false, false, false)] diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs index e9cceb98e..254cb34f5 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs @@ -11,13 +11,6 @@ internal static class ThirdPartyToolMigrationWizardStateRules { internal const int MigrationProgressUiUpdateIntervalMilliseconds = 100; - internal static bool ShouldStartInitialRefresh( - bool shouldRefreshAfterCreateGui, - bool shouldAutoScanThirdPartyToolMigration) - { - return shouldRefreshAfterCreateGui && shouldAutoScanThirdPartyToolMigration; - } - internal static bool ShouldReportMigrationProgress( long lastReportTimestamp, long currentTimestamp, diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 4e24b28f9..6a1c300d3 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -34,6 +34,7 @@ public class ThirdPartyToolMigrationWizardWindow : EditorWindow private SkillsTarget _migrationSkillTarget = SkillsTarget.Claude; private SkillInstallState _migrationSkillInstallState = SkillInstallState.Missing; private CancellationTokenSource _migrationOperationCts; + private CancellationTokenSource _migrationSkillOperationCts; private SkillSetupUseCase _skillSetupUseCase; private ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; private ThirdPartyToolMigrationWizardView _view; @@ -148,15 +149,6 @@ private static void LogAutoScanException(System.Exception ex) Debug.LogException(ex); } - internal static bool ShouldStartInitialRefresh( - bool shouldRefreshAfterCreateGui, - bool shouldAutoScanThirdPartyToolMigration) - { - return ThirdPartyToolMigrationWizardStateRules.ShouldStartInitialRefresh( - shouldRefreshAfterCreateGui, - shouldAutoScanThirdPartyToolMigration); - } - internal static bool ShouldOpenWindowAfterAutoScan( bool hasMigrationTargets, bool isCancellationRequested) @@ -396,6 +388,7 @@ private void OnDisable() { _resizer?.Pause(); CancelMigrationOperation(); + CancelMigrationSkillOperation(); } private void ShowInitialState(bool shouldStartInitialRefresh) @@ -427,22 +420,38 @@ private async void RefreshUI() string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); System.IProgress progress = CreateProgressReporter(ct); - ThirdPartyToolMigrationPreview preview = - await Task.Run(async () => + ThirdPartyToolMigrationPreview preview; + try + { + preview = await Task.Run(async () => await _thirdPartyToolMigrationUseCase.PreviewMigrationAsync(projectRoot, progress, ct)); - if (ct.IsCancellationRequested) + await MainThreadSwitcher.SwitchToMainThread(); + } + catch (System.OperationCanceledException) { + // Cancellation comes from window close or a superseding operation; that owner drives the UI. + return; + } + catch (System.Exception ex) + { + // Without this async-void boundary the exception hits the sync context, the window + // stays on "Scanning..." forever, and the operation CTS leaks. + Debug.LogException(ex); + if (IsMigrationOperationActive(ct)) + { + CompleteMigrationOperation(ct); + ShowNotCheckedState(); + } + return; } - await MainThreadSwitcher.SwitchToMainThread(); if (ct.IsCancellationRequested) { return; } CompleteMigrationOperation(ct); - if (!preview.HasTargets) { ShowNoMigrationTargetsState(); @@ -492,6 +501,19 @@ private async void HandleMigrateThirdPartyTools() isMigrationCompletionPending = false; } + catch (System.OperationCanceledException) + { + // The finally block already skips the interrupted-refresh when the token is canceled. + return; + } + catch (System.Exception ex) + { + // PR1 makes a mid-batch apply failure a designed path (rollback, then throw). Log it and + // return; the finally block sees the still-pending completion and rescans, so the UI + // reflects the rolled-back files. + Debug.LogException(ex); + return; + } finally { _isMigrating = false; @@ -579,6 +601,7 @@ private void HandleMigrationSkillTargetChanged(SkillsTarget target) private async void HandleToggleMigrationSkill() { + CancellationToken ct = BeginMigrationSkillOperation(); string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); SkillSetupTargetInfo target = CreateMigrationSkillTargetInfo(_migrationSkillTarget); SkillInstallState currentInstallState = @@ -600,21 +623,41 @@ await _skillSetupUseCase.RemoveV3MigrationSkillFilesAsync( projectRoot, targets, GroupMigrationSkillUnderUnityCliLoop, - CancellationToken.None); - return; + ct); + } + else + { + await _skillSetupUseCase.InstallV3MigrationSkillFilesAsync( + projectRoot, + targets, + GroupMigrationSkillUnderUnityCliLoop, + ct); } - - await _skillSetupUseCase.InstallV3MigrationSkillFilesAsync( - projectRoot, - targets, - GroupMigrationSkillUnderUnityCliLoop, - CancellationToken.None); + } + catch (System.OperationCanceledException) + { + // The window is closing or a newer toggle superseded this one; do not touch its UI. + return; + } + catch (System.Exception ex) + { + // Fall through so the tail refresh shows the real on-disk install state after a failure. + Debug.LogException(ex); } finally { _isUpdatingMigrationSkill = false; - RefreshMigrationSkillState(); } + + // The use case may complete off the main thread; UI Toolkit access below requires it. + await MainThreadSwitcher.SwitchToMainThread(); + if (!IsMigrationSkillOperationActive(ct)) + { + return; + } + + CompleteMigrationSkillOperation(ct); + RefreshMigrationSkillState(); } private static SkillSetupTargetInfo CreateMigrationSkillTargetInfo(SkillsTarget target) @@ -657,7 +700,27 @@ private void CancelMigrationOperation() _migrationOperationCts = null; } - private bool ConsumeShouldStartInitialRefresh() + private CancellationToken BeginMigrationSkillOperation() + { + CancelMigrationSkillOperation(); + CancellationTokenSource cts = new CancellationTokenSource(); + _migrationSkillOperationCts = cts; + return cts.Token; + } + + private void CancelMigrationSkillOperation() + { + if (_migrationSkillOperationCts == null) + { + return; + } + + _migrationSkillOperationCts.Cancel(); + _migrationSkillOperationCts.Dispose(); + _migrationSkillOperationCts = null; + } + + internal bool ConsumeShouldStartInitialRefresh() { if (!_shouldRefreshAfterCreateGui) { @@ -665,9 +728,7 @@ private bool ConsumeShouldStartInitialRefresh() } _shouldRefreshAfterCreateGui = false; - bool shouldAutoScanThirdPartyToolMigration = - GetSessionFlagsRepository().ConsumeShouldAutoScanThirdPartyToolMigration(); - return ShouldStartInitialRefresh(true, shouldAutoScanThirdPartyToolMigration); + return true; } private void TryStartInitialRefresh() @@ -697,5 +758,21 @@ private bool IsMigrationOperationActive(CancellationToken ct) { return _migrationOperationCts != null && _migrationOperationCts.Token.Equals(ct); } + + private void CompleteMigrationSkillOperation(CancellationToken ct) + { + if (!IsMigrationSkillOperationActive(ct)) + { + return; + } + + _migrationSkillOperationCts.Dispose(); + _migrationSkillOperationCts = null; + } + + private bool IsMigrationSkillOperationActive(CancellationToken ct) + { + return _migrationSkillOperationCts != null && _migrationSkillOperationCts.Token.Equals(ct); + } } } From a72bd5c7e383f62892b8a02f1958acea7efbd76d Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 10:51:08 +0900 Subject: [PATCH 6/9] fix: Confirm migration apply and list target files in the wizard (#1706) Co-authored-by: Cursor --- ...hirdPartyToolMigrationWizardWindowTests.cs | 128 +++++++++++++++--- ...ThirdPartyToolMigrationWizardStateRules.cs | 33 +++++ .../ThirdPartyToolMigrationWizardText.cs | 54 +++++++- .../ThirdPartyToolMigrationWizardView.cs | 7 +- .../ThirdPartyToolMigrationWizardWindow.cs | 37 ++++- 5 files changed, 231 insertions(+), 28 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs index bb899bd31..a4c4e1897 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -151,24 +151,116 @@ public async Task RunAutoScanAsync_WhenScanThrows_LogsExceptionAndConsumesState( Assert.That(loggedException, Is.SameAs(expectedException)); } - [TestCase( - 1, - "1 file needs V3 C# source structure 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 C# source structure 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 GetMigrationStatusText_WhenTargetsExist_IncludesRelativeFilePaths() + { + // Verifies the migration status lists project-relative target paths after the summary. + string projectRoot = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "UnityCliLoopMigrationStatusRoot", + System.Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(projectRoot); + try + { + string[] filePaths = + { + System.IO.Path.Combine(projectRoot, "Assets", "Vendor", "One.cs"), + System.IO.Path.Combine(projectRoot, "Assets", "Vendor", "Two.cs") + }; + + string text = ThirdPartyToolMigrationWizardWindow.GetMigrationStatusText( + filePaths, + projectRoot); + + Assert.That(text, Does.StartWith( + "2 files need V3 C# source structure 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.\n\n" + + "Files:\n")); + Assert.That(text, Does.Contain("Assets/Vendor/One.cs")); + Assert.That(text, Does.Contain("Assets/Vendor/Two.cs")); + Assert.That(text, Does.Not.Contain("\\")); + } + finally + { + System.IO.Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void NormalizeDisplayPathSeparators_WhenPathsUseBackslashes_NormalizesToForwardSlashes() + { + // Verifies displayed migration paths always use forward slashes on every platform. + string normalized = ThirdPartyToolMigrationWizardStateRules.NormalizeDisplayPathSeparators( + "Assets\\Vendor\\Tool.cs"); + + Assert.That(normalized, Is.EqualTo("Assets/Vendor/Tool.cs")); + } + + [Test] + public void FormatMigrationTargetPathsForStatus_WhenPathCountExceedsLimit_AppendsOverflowSummary() + { + // Verifies long target lists truncate with a +N more files suffix. + string projectRoot = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "UnityCliLoopMigrationOverflowRoot", + System.Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(projectRoot); + try + { + int totalCount = ThirdPartyToolMigrationWizardStateRules.MaxMigrationTargetPathsInStatus + 3; + string[] filePaths = new string[totalCount]; + for (int index = 0; index < totalCount; index++) + { + filePaths[index] = System.IO.Path.Combine(projectRoot, "Assets", $"File{index:D3}.cs"); + } + + string text = ThirdPartyToolMigrationWizardText.FormatMigrationTargetPathsForStatus( + filePaths, + projectRoot); + + Assert.That(text, Does.Contain("Assets/File000.cs")); + Assert.That( + text, + Does.Contain( + $"Assets/File{(ThirdPartyToolMigrationWizardStateRules.MaxMigrationTargetPathsInStatus - 1):D3}.cs")); + Assert.That(text, Does.Contain("+3 more files")); + Assert.That(text, Does.Not.Contain($"Assets/File{ThirdPartyToolMigrationWizardStateRules.MaxMigrationTargetPathsInStatus:D3}.cs")); + } + finally + { + System.IO.Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ConfirmMigrationApply_WhenDialogIsCanceled_ReturnsFalse() + { + // Verifies Cancel leaves migration unstarted by returning false from the confirm helper. + bool confirmed = ThirdPartyToolMigrationWizardWindow.ConfirmMigrationApply( + 2, + (title, message, ok, cancel) => + { + Assert.That(title, Is.EqualTo(ThirdPartyToolMigrationWizardText.MigrationConfirmDialogTitle)); + Assert.That(message, Does.Contain("2 files will be rewritten in place.")); + Assert.That(message, Does.Contain("VCS recommended")); + Assert.That(ok, Is.EqualTo(ThirdPartyToolMigrationWizardText.MigrationConfirmDialogOkText)); + Assert.That(cancel, Is.EqualTo(ThirdPartyToolMigrationWizardText.MigrationConfirmDialogCancelText)); + return false; + }); + + Assert.That(confirmed, Is.False); + } + + [Test] + public void ConfirmMigrationApply_WhenDialogIsAccepted_ReturnsTrue() + { + // Verifies Migrate confirmation allows the apply path to continue. + bool confirmed = ThirdPartyToolMigrationWizardWindow.ConfirmMigrationApply( + 1, + (_, _, _, _) => true); + + Assert.That(confirmed, Is.True); } [Test] diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs index 254cb34f5..1076ce24d 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardStateRules.cs @@ -10,6 +10,39 @@ namespace io.github.hatayama.UnityCliLoop.Presentation internal static class ThirdPartyToolMigrationWizardStateRules { internal const int MigrationProgressUiUpdateIntervalMilliseconds = 100; + internal const int MaxMigrationTargetPathsInStatus = 50; + + internal static int GetMigrationTargetPathsOverflowCount(int totalPathCount, int maxPaths) + { + Debug.Assert(totalPathCount >= 0, "totalPathCount must not be negative"); + Debug.Assert(maxPaths > 0, "maxPaths must be positive"); + + if (totalPathCount <= maxPaths) + { + return 0; + } + + return totalPathCount - maxPaths; + } + + internal static string NormalizeDisplayPathSeparators(string path) + { + Debug.Assert(path != null, "path must not be null"); + + // Status text must stay Windows-safe: never leave backslashes in displayed relative paths. + return path.Replace('\\', '/'); + } + + internal static string ToProjectRelativeDisplayPath(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 = System.IO.Path.GetRelativePath( + System.IO.Path.GetFullPath(projectRoot), + System.IO.Path.GetFullPath(filePath)); + return NormalizeDisplayPathSeparators(relativePath); + } internal static bool ShouldReportMigrationProgress( long lastReportTimestamp, diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs index e60fdf4e6..da1c3d710 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardText.cs @@ -29,6 +29,9 @@ internal static class ThirdPartyToolMigrationWizardText "- After editing, summarize changed files, remaining candidates, and any commands I should verify manually."; internal const string MigrationNotCheckedText = "C# source migration status has not been checked."; internal const string NoMigrationTargetsText = "No C# source structure migration is needed."; + internal const string MigrationConfirmDialogTitle = "Migrate C# Sources?"; + internal const string MigrationConfirmDialogOkText = "Migrate"; + internal const string MigrationConfirmDialogCancelText = "Cancel"; private const string MigrationCheckingText = "Scanning C# source files for V3 custom tool API migration..."; private const string MigrationApplyingText = "Migrating C# source files to V3 custom tool APIs..."; private const string MigrationButtonReadyText = "Migrate"; @@ -39,11 +42,14 @@ internal static class ThirdPartyToolMigrationWizardText private const string RemoveMigrationSkillButtonText = "Remove Migration Skill"; private const string UpdatingMigrationSkillButtonText = "Updating..."; private const string CopyMigrationSkillPromptButtonText = "Copy AI Prompt"; + private const string MigrationTargetFilesHeading = "Files:"; - internal static string GetMigrationStatusText(int fileCount) + internal static string GetMigrationStatusText(string[] filePaths, string projectRoot) { - Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + Debug.Assert(filePaths != null, "filePaths must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + int fileCount = filePaths.Length; string noun = fileCount == 1 ? "file" : "files"; string verb = fileCount == 1 ? "needs" : "need"; string subject = fileCount == 1 ? "this file still uses" : "these files still use"; @@ -52,7 +58,49 @@ internal static string GetMigrationStatusText(int fileCount) return $"{fileCount} {noun} {verb} V3 C# source structure migration.\n" + $"The Unity Console is showing errors because {subject} the old custom tool API.\n\n" + $"Click Migrate to update {objectPronoun} automatically. " + - "The errors should disappear after migration."; + "The errors should disappear after migration.\n\n" + + FormatMigrationTargetPathsForStatus(filePaths, projectRoot); + } + + internal static string GetMigrationConfirmDialogMessage(int fileCount) + { + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + + string noun = fileCount == 1 ? "file" : "files"; + return $"{fileCount} {noun} will be rewritten in place.\n\n" + + "Commit or back up your project first (VCS recommended)."; + } + + internal static string FormatMigrationTargetPathsForStatus(string[] filePaths, string projectRoot) + { + Debug.Assert(filePaths != null, "filePaths must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + int maxPaths = ThirdPartyToolMigrationWizardStateRules.MaxMigrationTargetPathsInStatus; + int overflowCount = ThirdPartyToolMigrationWizardStateRules.GetMigrationTargetPathsOverflowCount( + filePaths.Length, + maxPaths); + int displayCount = filePaths.Length - overflowCount; + System.Text.StringBuilder builder = new(); + builder.Append(MigrationTargetFilesHeading); + for (int index = 0; index < displayCount; index++) + { + builder.Append('\n'); + builder.Append( + ThirdPartyToolMigrationWizardStateRules.ToProjectRelativeDisplayPath( + filePaths[index], + projectRoot)); + } + + if (overflowCount > 0) + { + builder.Append('\n'); + builder.Append('+'); + builder.Append(overflowCount); + builder.Append(" more files"); + } + + return builder.ToString(); } internal static string GetMigrationProgressText( diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs index 256e0da94..68ead776f 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardView.cs @@ -133,10 +133,13 @@ internal void ShowNotCheckedState(bool isMigrating) _refreshButton.SetEnabled(true); } - internal void ShowMigrationTargetsState(int fileCount, bool isMigrating) + internal void ShowMigrationTargetsState(string[] filePaths, string projectRoot, bool isMigrating) { + Debug.Assert(filePaths != null, "filePaths must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + _migrationStatusTextField.SetValueWithoutNotify( - ThirdPartyToolMigrationWizardText.GetMigrationStatusText(fileCount)); + ThirdPartyToolMigrationWizardText.GetMigrationStatusText(filePaths, projectRoot)); ViewDataBinder.SetVisible(_migrationProgressBar, false); ViewDataBinder.SetVisible(_migrationButtonRow, true); _migrateButton.SetEnabled(!isMigrating); diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs index 6a1c300d3..dc9a8142f 100644 --- a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -33,6 +34,7 @@ public class ThirdPartyToolMigrationWizardWindow : EditorWindow private bool _isUpdatingMigrationSkill; private SkillsTarget _migrationSkillTarget = SkillsTarget.Claude; private SkillInstallState _migrationSkillInstallState = SkillInstallState.Missing; + private string[] _pendingMigrationFilePaths = Array.Empty(); private CancellationTokenSource _migrationOperationCts; private CancellationTokenSource _migrationSkillOperationCts; private SkillSetupUseCase _skillSetupUseCase; @@ -184,9 +186,23 @@ internal static Rect WithContentHeight(Rect currentRect, float contentHeight, Ve frameSize); } - internal static string GetMigrationStatusText(int fileCount) + internal static string GetMigrationStatusText(string[] filePaths, string projectRoot) { - return ThirdPartyToolMigrationWizardText.GetMigrationStatusText(fileCount); + return ThirdPartyToolMigrationWizardText.GetMigrationStatusText(filePaths, projectRoot); + } + + internal static bool ConfirmMigrationApply( + int fileCount, + Func displayDialog) + { + Debug.Assert(displayDialog != null, "displayDialog must not be null"); + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + + return displayDialog( + ThirdPartyToolMigrationWizardText.MigrationConfirmDialogTitle, + ThirdPartyToolMigrationWizardText.GetMigrationConfirmDialogMessage(fileCount), + ThirdPartyToolMigrationWizardText.MigrationConfirmDialogOkText, + ThirdPartyToolMigrationWizardText.MigrationConfirmDialogCancelText); } internal static string GetMigrationProgressText( @@ -458,11 +474,18 @@ private async void RefreshUI() return; } - ShowMigrationTargetsState(preview.FileCount); + ShowMigrationTargetsState(preview.FilePaths); } private async void HandleMigrateThirdPartyTools() { + if (!ConfirmMigrationApply( + _pendingMigrationFilePaths.Length, + (title, message, ok, cancel) => EditorUtility.DisplayDialog(title, message, ok, cancel))) + { + return; + } + CancellationToken ct = BeginMigrationOperation(); string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); ThirdPartyToolMigrationResult result = default; @@ -555,9 +578,13 @@ private void ShowNotCheckedState() ScheduleResizeToContent(); } - private void ShowMigrationTargetsState(int fileCount) + private void ShowMigrationTargetsState(string[] filePaths) { - _view.ShowMigrationTargetsState(fileCount, _isMigrating); + Debug.Assert(filePaths != null, "filePaths must not be null"); + + _pendingMigrationFilePaths = filePaths; + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + _view.ShowMigrationTargetsState(filePaths, projectRoot, _isMigrating); ScheduleResizeToContent(); } From f414de6b2127c27c87e59df96fda4aa6d203758c Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 10:55:50 +0900 Subject: [PATCH 7/9] fix: Expand temporary V3 migration skill removed-command guidance (#1707) Co-authored-by: Cursor --- .../v3-cli-invocation-migration/Skill/SKILL.md | 15 ++++++++++----- .../Skill/references/first-party-v2-to-v3.md | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md index 3dfa7c198..bfad39dbe 100644 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/SKILL.md @@ -5,6 +5,8 @@ description: Migrate only uloop V2 CLI option syntax in agent skills, Markdown, # V3 CLI Invocation Migration +Agent-facing CLI migration candidates live in this skill. Installed-skill cleanup names stay in `SkillTargetInstaller` / `skills.go` `deprecatedSkillNames`. + Use this skill to update V2-era `uloop` CLI option syntax to V3 syntax in agent-facing docs and automation. @@ -37,8 +39,9 @@ Prefer `rg` for searches when available. If `rg` is unavailable, use the best av - For third-party tools, check the tool's current schema or documentation before deciding whether `false` means removal, a `--no-*` flag, or a renamed option. -- For first-party tools, use the reference table. `compile` and `run-tests` - have special renamed negative flags. +- For first-party tools, use the reference table. `compile`, `run-tests`, + `get-hierarchy`, `record-input`, and `replay-input` have special renamed + negative flags. - A valid edit is limited to replacing, adding, or removing a `uloop` option token and the boolean value attached to that option in the same invocation. - Preserve surrounding Markdown, shell, and PowerShell formatting. Do not @@ -63,6 +66,8 @@ Prefer `rg` for searches when available. If `rg` is unavailable, use the best av - `uloop` command lines and examples. - Boolean-looking CLI options: `--* true`, `--*=true`, `--* false`, `--*=false`. - First-party renamed options from the reference, including bare flags. -- Removed commands: `get-project-info` and `get-version`. Report these as - out-of-scope command migration candidates unless the user explicitly asked - to migrate removed commands. +- Removed or renamed commands: `get-project-info`, `get-version`, + `unity-search`, `execute-menu-item`, `get-menu-items`, + `get-unity-search-providers`, `get-provider-details`, and `capture-window` + (renamed to `screenshot`). Report these as out-of-scope command migration + candidates unless the user explicitly asked to migrate removed commands. diff --git a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md index 21e00d48c..382e9f7c7 100644 --- a/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md +++ b/Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill/references/first-party-v2-to-v3.md @@ -1,5 +1,7 @@ # First-Party V2 to V3 CLI Migration +Agent-facing CLI migration candidates live here. Installed-skill cleanup names stay in `SkillTargetInstaller` / `skills.go` `deprecatedSkillNames`. + Use this reference as the canonical option migration map. Search results are only candidates. Edit a match only after the surrounding context proves it is a V2 `uloop` invocation. @@ -21,8 +23,10 @@ Prefer `rg` when available, but any repository search tool is acceptable. - Search `uloop` first and inspect command examples, shell scripts, PowerShell scripts, and agent skills. - Search boolean-looking CLI syntax: `--` plus nearby `true` or `false`, including `--flag true`, `--flag=false`, and inline Markdown command examples. - Search renamed first-party option names: `wait-for-domain-reload`, `reload-external-scene-changes`, `force-recompile`, `save-before-run`, `show-overlay`, `include-components`, `include-inactive`, and `compile-only`. -- Search removed first-party commands only to report them as out-of-scope - command migration candidates: `get-project-info` and `get-version`. +- Search removed or renamed first-party commands only to report them as + out-of-scope command migration candidates: `get-project-info`, `get-version`, + `unity-search`, `execute-menu-item`, `get-menu-items`, + `get-unity-search-providers`, `get-provider-details`, and `capture-window`. - Skip generated installed skill copies under `.agents`, `.claude`, `.codex`, `.cursor`, `.gemini`, `.windsurf`, `.agent`, or equivalent target folders unless the user explicitly asks to migrate installed copies. ## Boolean Argument Rules @@ -30,9 +34,13 @@ Prefer `rg` when available, but any repository search tool is acceptable. | V2 form | V3 form | | --- | --- | | `--flag true` | `--flag` when the V3 option is a positive default-false boolean | +| `--flag=true` | `--flag` when the V3 option is a positive default-false boolean | | `--flag=false` | remove the option when the V3 default is already false | +| `--flag false` | remove the option when the V3 default is already false | | `--flag true` | remove the option when the V3 default is already true | +| `--flag=true` | remove the option when the V3 default is already true | | `--flag false` | use the V3 negative option when the V3 default is true | +| `--flag=false` | use the V3 negative option when the V3 default is true | For third-party tools, inspect the current tool schema or docs before choosing the replacement. Do not infer third-party negative flags from first-party conventions. @@ -65,3 +73,9 @@ For third-party tools, inspect the current tool schema or docs before choosing t | --- | --- | | `uloop get-project-info` | Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | | `uloop get-version` | Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | +| `uloop unity-search` | Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | +| `uloop execute-menu-item` | Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | +| `uloop get-menu-items` | Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | +| `uloop get-unity-search-providers` | Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | +| `uloop get-provider-details` | Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | +| `uloop capture-window` | Renamed to `uloop screenshot`. Report only unless the user explicitly asks for removed command migration. Do not guess from the command name alone. | From cfc340ed08944e6d4bc20b41fea709b0f452d1ad Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 11:00:06 +0900 Subject: [PATCH 8/9] fix: Clean alternate V3 migration skill layouts on skip (#1708) Co-authored-by: Cursor --- .../internal/dispatcher/skills_test.go | 172 ++++++++++++++++++ .../dispatcher/skills_v3_migration.go | 14 +- 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/cli/dispatcher/internal/dispatcher/skills_test.go b/cli/dispatcher/internal/dispatcher/skills_test.go index 05b587d58..a74f93444 100644 --- a/cli/dispatcher/internal/dispatcher/skills_test.go +++ b/cli/dispatcher/internal/dispatcher/skills_test.go @@ -1105,6 +1105,154 @@ func TestRunV3MigrationSkillUninstallRemovesAlternateLayout(t *testing.T) { } } +// Tests that an already-installed V3 migration skill still removes the alternate layout. +func TestInstallV3MigrationSkillForTargetRemovesAlternateLayoutWhenInstalled(t *testing.T) { + projectRoot := t.TempDir() + skillContent := `--- +name: v3-cli-invocation-migration +--- + +# temporary migration +` + writeV3MigrationSkillFixture(t, projectRoot, skillContent) + skills, err := collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + t.Fatalf("collectV3MigrationSkillDefinition failed: %v", err) + } + target := targetConfigs["codex"] + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + flatDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, false) + groupedDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, true) + if err := syncSkillDirectory(skills[0].sourceDirectory, flatDir); err != nil { + t.Fatalf("seed flat install failed: %v", err) + } + writeSkillFile(t, groupedDir, skillContent) + + result, err := installV3MigrationSkillForTarget(projectRoot, target, skills, false, false) + if err != nil { + t.Fatalf("installV3MigrationSkillForTarget failed: %v", err) + } + if result.installed != 0 || result.updated != 0 || result.skipped != 1 { + t.Fatalf("install result mismatch: %#v", result) + } + if _, err := os.Stat(filepath.Join(flatDir, "SKILL.md")); err != nil { + t.Fatalf("preferred flat skill should remain: %v", err) + } + if _, err := os.Stat(groupedDir); !os.IsNotExist(err) { + t.Fatalf("alternate grouped skill should be removed: %v", err) + } +} + +// Tests that an outdated V3 migration skill is re-synced and counted as updated. +func TestInstallV3MigrationSkillForTargetUpdatesOutdatedSkill(t *testing.T) { + projectRoot := t.TempDir() + writeV3MigrationSkillFixture(t, projectRoot, `--- +name: v3-cli-invocation-migration +--- + +# temporary migration v1 +`) + skills, err := collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + t.Fatalf("collectV3MigrationSkillDefinition failed: %v", err) + } + target := targetConfigs["codex"] + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + flatDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, false) + if err := syncSkillDirectory(skills[0].sourceDirectory, flatDir); err != nil { + t.Fatalf("seed flat install failed: %v", err) + } + + updatedContent := `--- +name: v3-cli-invocation-migration +--- + +# temporary migration v2 +` + writeV3MigrationSkillFixture(t, projectRoot, updatedContent) + skills, err = collectV3MigrationSkillDefinition(projectRoot) + if err != nil { + t.Fatalf("collectV3MigrationSkillDefinition failed after update: %v", err) + } + + result, err := installV3MigrationSkillForTarget(projectRoot, target, skills, false, false) + if err != nil { + t.Fatalf("installV3MigrationSkillForTarget failed: %v", err) + } + if result.installed != 0 || result.updated != 1 || result.skipped != 0 { + t.Fatalf("install result mismatch: %#v", result) + } + installedContent, err := os.ReadFile(filepath.Join(flatDir, "SKILL.md")) + if err != nil { + t.Fatalf("read installed skill failed: %v", err) + } + if !strings.Contains(string(installedContent), "temporary migration v2") { + t.Fatalf("outdated skill should be re-synced: %s", installedContent) + } +} + +// Tests that uninstalling a grouped V3 migration skill removes an empty managed parent directory. +func TestRunV3MigrationSkillUninstallRemovesEmptyGroupedParent(t *testing.T) { + projectRoot := t.TempDir() + target := targetConfigs["codex"] + baseDir, err := getSkillsBaseDir(projectRoot, target, false) + if err != nil { + t.Fatalf("getSkillsBaseDir failed: %v", err) + } + groupedDir := getPreferredSkillDir(baseDir, v3MigrationSkillName, true) + managedParent := filepath.Join(baseDir, managedSkillsDir) + writeSkillFile(t, groupedDir, "---\nname: v3-cli-invocation-migration\n---\n") + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + options := skillCommandOptions{targets: []skillTarget{target}} + + code := runV3MigrationSkillUninstall(projectRoot, options, stdout, stderr) + + if code != 0 { + t.Fatalf("uninstall should succeed: code=%d stderr=%s", code, stderr.String()) + } + if _, err := os.Stat(groupedDir); !os.IsNotExist(err) { + t.Fatalf("grouped migration skill should be removed: %v", err) + } + if _, err := os.Stat(managedParent); !os.IsNotExist(err) { + t.Fatalf("empty managed parent should be removed: %v", err) + } +} + +// Tests that install-v3-migration without targets prints guidance instead of installing. +func TestRunV3MigrationSkillsSubcommandWithoutTargetsPrintsGuidance(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + code := runV3MigrationSkillsSubcommand( + "install-v3-migration", + t.TempDir(), + skillCommandOptions{}, + stdout, + stderr, + ) + + if code != 0 { + t.Fatalf("guidance path should succeed: code=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, "Please specify at least one target for 'install-v3-migration'") { + t.Fatalf("guidance should name the subcommand: %s", output) + } + for _, id := range allSkillTargetIDs { + flag := "--" + id + if !strings.Contains(output, flag) { + t.Fatalf("target guidance missing %s:\n%s", flag, output) + } + } +} + // writeTestSkill seeds a SKILL.md fixture at projectRoot/relativeDir via the // shared clitest.WriteSkillFile helper, which owns the CRLF normalization. func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, content string) { @@ -1112,6 +1260,30 @@ func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, conten clitest.WriteSkillFile(t, projectRoot, relativeDir, "SKILL.md", content) } +func writeV3MigrationSkillFixture(t *testing.T, projectRoot string, skillContent string) { + t.Helper() + writePackageRootMarker(t, projectRoot) + writeTestSkill(t, projectRoot, "Packages/src/TemporarySkills~/v3-cli-invocation-migration/Skill", skillContent) + referenceDir := filepath.Join( + projectRoot, + "Packages", + "src", + "TemporarySkills~", + "v3-cli-invocation-migration", + "Skill", + "references", + ) + if err := os.MkdirAll(referenceDir, 0o755); err != nil { + t.Fatalf("failed to create reference dir: %v", err) + } + if err := os.WriteFile( + filepath.Join(referenceDir, "first-party-v2-to-v3.md"), + []byte("# reference\n"), + 0o644); err != nil { + t.Fatalf("failed to write reference: %v", err) + } +} + func writeManifest(t *testing.T, projectRoot string, content string) { t.Helper() manifestDir := filepath.Join(projectRoot, "Packages") diff --git a/cli/dispatcher/internal/dispatcher/skills_v3_migration.go b/cli/dispatcher/internal/dispatcher/skills_v3_migration.go index 628024e96..c15cd9853 100644 --- a/cli/dispatcher/internal/dispatcher/skills_v3_migration.go +++ b/cli/dispatcher/internal/dispatcher/skills_v3_migration.go @@ -70,17 +70,19 @@ func installV3MigrationSkillForTarget(projectRoot string, target skillTarget, sk return skillInstallResult{}, err } destinationDir := getPreferredSkillDir(baseDir, skill.name, grouped) - if status == "installed" { - result.skipped++ - continue - } - if err := syncSkillDirectory(skill.sourceDirectory, destinationDir); err != nil { - return skillInstallResult{}, err + if status != "installed" { + if err := syncSkillDirectory(skill.sourceDirectory, destinationDir); err != nil { + return skillInstallResult{}, err + } } alternateDir := getPreferredSkillDir(baseDir, skill.name, !grouped) if err := os.RemoveAll(alternateDir); err != nil { return skillInstallResult{}, err } + if status == "installed" { + result.skipped++ + continue + } if status == "outdated" { result.updated++ continue From 76fa4644aab754bd021969076a971a3538452fea Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 11:06:05 +0900 Subject: [PATCH 9/9] fix: Harden migration scans against symlink cycles and ignored folders (#1709) Co-authored-by: Cursor --- ...yToolMigrationProjectFileInventoryTests.cs | 69 +++++++++++++++++++ ...MigrationProjectFileInventoryTests.cs.meta | 11 +++ .../ThirdPartyToolMigrationRulesTests.cs | 21 ++++++ ...dPartyToolMigrationSourceDetectionRules.cs | 7 ++ ...dPartyToolMigrationProjectFileInventory.cs | 22 ++++++ 5 files changed, 130 insertions(+) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs new file mode 100644 index 000000000..09cae48f8 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies migration project file inventory symlink/junction cycle guards. + /// + public sealed class ThirdPartyToolMigrationProjectFileInventoryTests + { + [Test] + public void Create_WhenAssetsContainsSelfReferentialSymbolicLink_CompletesWithoutLooping() + { + // Verifies a real directory symlink cycle is skipped and the inventory walk completes. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("Directory symlink creation requires elevated privileges on Windows."); + } + + string projectRoot = CreateProjectRoot(); + try + { + string assetsDirectory = Path.Combine(projectRoot, "Assets"); + string vendorDirectory = Path.Combine(assetsDirectory, "VendorTools"); + string cycleLinkDirectory = Path.Combine(vendorDirectory, "Cycle"); + Directory.CreateDirectory(vendorDirectory); + string includedPath = Path.Combine(vendorDirectory, "IncludedTool.cs"); + File.WriteAllText(includedPath, "public sealed class IncludedTool {}"); + CreateDirectorySymbolicLink(cycleLinkDirectory, vendorDirectory); + + ProjectFileInventory inventory = ProjectFileInventory.Create(projectRoot); + + Assert.That(inventory.CSharpFilePaths, Does.Contain(includedPath)); + Assert.That( + inventory.CSharpFilePaths, + Has.None.Matches(path => path.IndexOf("Cycle", StringComparison.Ordinal) >= 0)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + private static string CreateProjectRoot() + { + string projectRoot = Path.Combine( + Path.GetTempPath(), + "UnityCliLoopMigrationInventoryTests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Assets")); + Directory.CreateDirectory(Path.Combine(projectRoot, "ProjectSettings")); + return projectRoot; + } + + private static void CreateDirectorySymbolicLink(string linkPath, string targetPath) + { + int result = Symlink(targetPath, linkPath); + Assert.That(result, Is.EqualTo(0), $"symlink failed for {linkPath} -> {targetPath}"); + } + + [DllImport("libc", EntryPoint = "symlink", SetLastError = true)] + private static extern int Symlink(string targetPath, string linkPath); + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs.meta new file mode 100644 index 000000000..a053c1989 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationProjectFileInventoryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25c7741ecc6534e7aa0beb64ecbabcc5 +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 index 0fe67abad..ec4db6d06 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs @@ -4522,5 +4522,26 @@ public void GetExcludedDirectoryNames_IncludesUnityGeneratedDirectories() Assert.That(names.Contains("Temp"), Is.True); Assert.That(names.Contains(".git"), Is.True); } + + [Test] + public void IsExcludedDirectoryName_WhenDirectoryEndsWithTilde_ReturnsTrue() + { + // Verifies Unity trailing-~ folders such as Samples~ are excluded from migration scans. + Assert.That(ThirdPartyToolMigrationRules.IsExcludedDirectoryName("Samples~"), Is.True); + } + + [Test] + public void IsExcludedDirectoryName_WhenDirectoryStartsWithDot_ReturnsTrue() + { + // Verifies hidden/dot folders are excluded even when not listed by exact name. + Assert.That(ThirdPartyToolMigrationRules.IsExcludedDirectoryName(".hidden"), Is.True); + } + + [Test] + public void IsExcludedDirectoryName_WhenDirectoryIsOrdinaryAssetFolder_ReturnsFalse() + { + // Verifies normal Assets subfolders remain eligible for migration scanning. + Assert.That(ThirdPartyToolMigrationRules.IsExcludedDirectoryName("VendorTools"), Is.False); + } } } diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationSourceDetectionRules.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationSourceDetectionRules.cs index 47740e503..ff6eec78d 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationSourceDetectionRules.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationSourceDetectionRules.cs @@ -270,6 +270,13 @@ public static bool IsExcludedDirectoryName(string directoryName) { Debug.Assert(!string.IsNullOrEmpty(directoryName), "directoryName must not be null or empty"); + // Unity ignores trailing-~ folders (Samples~) and hidden/dot folders; keep them out of scans. + if (directoryName.StartsWith(".", StringComparison.Ordinal) || + directoryName.EndsWith("~", StringComparison.Ordinal)) + { + return true; + } + return ExcludedDirectoryNames.Any( excludedDirectoryName => string.Equals( excludedDirectoryName, diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs index 13b49e0f6..04e81d880 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationProjectFileInventory.cs @@ -227,10 +227,32 @@ internal static bool ShouldExcludeDirectory(string projectRoot, string directory return true; } + // why not follow symlinks/junctions: cycle prevention takes priority over scanning + // through linked package trees that some developers keep under Assets. + if (!Directory.Exists(directoryPath)) + { + // Dangling reparse points report Exists=false; skip before reading Attributes. + return true; + } + + if (IsReparsePointDirectory(directoryPath)) + { + return true; + } + string directoryName = Path.GetFileName(directoryPath); return ThirdPartyToolMigrationRules.IsExcludedDirectoryName(directoryName); } + private static bool IsReparsePointDirectory(string directoryPath) + { + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + Debug.Assert(Directory.Exists(directoryPath), "directoryPath must exist before reading attributes"); + + DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath); + return (directoryInfo.Attributes & FileAttributes.ReparsePoint) != 0; + } + private static bool IsProjectRootPackagesDirectory(string projectRoot, string directoryPath) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");