From 3f915d676a268a13b201dcb50dd9d675a6447a60 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 25 Jun 2026 01:05:44 +0900 Subject: [PATCH 1/2] Fix migration file access for long Windows paths Route third-party migration reads and writes through extended-length Windows paths so preview and apply do not fail on MAX_PATH-sized project files. Add Windows-only regression coverage for long-path preview and apply flows. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 109 +++++++++++++++++ ...dPartyToolMigrationAsmdefMetaGuidReader.cs | 4 +- ...ationAsmdefMigrationRequirementResolver.cs | 2 +- ...yToolMigrationAssemblyReferenceResolver.cs | 5 +- ...PartyToolMigrationAssemblyUsageAnalyzer.cs | 2 +- ...grationFastAssemblyRequirementCollector.cs | 9 +- ...tyToolMigrationFastSourceTargetDetector.cs | 3 +- ...dPartyToolMigrationFileServiceConstants.cs | 4 + .../ThirdPartyToolMigrationFileWriter.cs | 111 +++++++++++++++++- .../ThirdPartyToolMigrationPlanBuilder.cs | 6 +- ...ThirdPartyToolMigrationPreflightScanner.cs | 2 +- .../ThirdPartyToolMigrationSourceFileCache.cs | 3 +- .../ThirdPartyToolMigrationTargetScanner.cs | 4 +- 13 files changed, 238 insertions(+), 26 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index ce74fc2293..9b6d31ef0e 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -12,6 +12,8 @@ using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.Infrastructure; +using static io.github.hatayama.UnityCliLoop.Infrastructure.ThirdPartyToolMigrationFileServiceConstants; + namespace io.github.hatayama.UnityCliLoop.Tests.Editor { /// @@ -70,6 +72,80 @@ public void ThirdPartyToolMigrationSourceFileCache_WhenFileIsReadTwice_UsesCache Assert.That(readCount, Is.EqualTo(1)); } + [Test] + [Platform(Include = "Win")] + public void ThirdPartyToolMigrationSourceFileCache_WhenWindowsPathExceedsLegacyLimit_ReadsSource() + { + // Verifies that migration preview can inspect source files beyond the legacy Windows path limit. + string projectRoot = CreateProjectRoot(); + try + { + string longDirectoryPath = CreateLongDirectoryPath(projectRoot); + string filePath = Path.Combine(longDirectoryPath, "LongPathTool.cs"); + Directory.CreateDirectory(ToWindowsExtendedLengthPath(longDirectoryPath)); + File.WriteAllText( + ToWindowsExtendedLengthPath(filePath), + "public sealed class LongPathTool {}"); + + Assert.That(filePath.Length, Is.GreaterThanOrEqualTo(WindowsLegacyMaxPathLength)); + ThirdPartyToolMigrationSourceFileCache cache = new(); + + string source = cache.ReadAllText(filePath); + + Assert.That(source, Does.Contain("LongPathTool")); + } + finally + { + Directory.Delete(ToWindowsExtendedLengthPath(projectRoot), recursive: true); + } + } + + [Test] + [Platform(Include = "Win")] + public async Task ApplyMigrationAsync_WhenWindowsPathExceedsLegacyLimit_MigratesSource() + { + // Verifies that migration apply can rewrite source files beyond the legacy Windows path limit. + string projectRoot = CreateProjectRoot(); + try + { + string longDirectoryPath = CreateLongDirectoryPath(projectRoot); + string filePath = Path.Combine(longDirectoryPath, "LongPathTool.cs"); + Directory.CreateDirectory(ToWindowsExtendedLengthPath(longDirectoryPath)); + File.WriteAllText( + ToWindowsExtendedLengthPath(filePath), + @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class LongPathTool : AbstractUnityTool +{ +} + +public sealed class LongPathSchema : BaseToolSchema +{ +} + +public sealed class LongPathResponse : BaseToolResponse +{ +}"); + + ThirdPartyToolMigrationFileService service = new(); + + ThirdPartyToolMigrationResult result = + await service.ApplyMigrationAsync( + projectRoot, + new Progress(), + CancellationToken.None); + string migratedSource = File.ReadAllText(ToWindowsExtendedLengthPath(filePath)); + + Assert.That(result.FilePaths.Contains(filePath), Is.True); + Assert.That(migratedSource, Does.Contain("UnityCliLoopTool")); + } + finally + { + Directory.Delete(ToWindowsExtendedLengthPath(projectRoot), recursive: true); + } + } + [Test] public void MigrationProjectFingerprint_WhenCandidateFileChanges_DoesNotMatch() { @@ -5472,6 +5548,39 @@ private static string CreateProjectRoot() return projectRoot; } + private static string CreateLongDirectoryPath(string projectRoot) + { + Assert.That(projectRoot, Is.Not.Empty); + + string directoryPath = Path.Combine(projectRoot, "Assets"); + int segmentIndex = 0; + while (Path.Combine(directoryPath, "LongPathTool.cs").Length < WindowsLegacyMaxPathLength) + { + directoryPath = Path.Combine(directoryPath, "LongPathSegment" + segmentIndex.ToString("D2")); + segmentIndex++; + } + + return directoryPath; + } + + private static string ToWindowsExtendedLengthPath(string path) + { + Assert.That(path, Is.Not.Empty); + + string fullPath = Path.GetFullPath(path); + if (fullPath.StartsWith(WindowsExtendedLengthPathPrefix, StringComparison.Ordinal)) + { + return fullPath; + } + + if (fullPath.StartsWith(WindowsUncPathPrefix, StringComparison.Ordinal)) + { + return WindowsExtendedLengthUncPathPrefix + fullPath.Substring(WindowsUncPathPrefix.Length); + } + + return WindowsExtendedLengthPathPrefix + fullPath; + } + private sealed class RecordingMigrationProgress : IProgress { private readonly List _reports; diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMetaGuidReader.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMetaGuidReader.cs index 07e6f5f6a6..8492407f41 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMetaGuidReader.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMetaGuidReader.cs @@ -15,12 +15,12 @@ internal static string ReadAsmdefGuidReferenceFromAsmdefPath(string asmdefFilePa Debug.Assert(!string.IsNullOrEmpty(asmdefFilePath), "asmdefFilePath must not be null or empty"); string metaPath = asmdefFilePath + ".meta"; - if (!File.Exists(metaPath)) + if (!ThirdPartyToolMigrationFileAccess.Exists(metaPath)) { return string.Empty; } - return ReadAsmdefGuidReferenceFromMetaFile(metaPath, File.ReadLines); + return ReadAsmdefGuidReferenceFromMetaFile(metaPath, ThirdPartyToolMigrationFileAccess.ReadLines); } internal static string ReadAsmdefGuidReferenceFromMetaFile( diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMigrationRequirementResolver.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMigrationRequirementResolver.cs index f9e7192014..74f1346cf7 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMigrationRequirementResolver.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAsmdefMigrationRequirementResolver.cs @@ -30,7 +30,7 @@ internal static bool ContainsAsmdefMigrationTarget( out requiresApplicationReference, out requiresDomainReference, out requiresFirstPartyScreenshotReference); - string source = File.ReadAllText(asmdefFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(asmdefFilePath); if (!hasAssemblyMigrationRequirement && !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source)) { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyReferenceResolver.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyReferenceResolver.cs index 62ebc5ff59..140448fc2e 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyReferenceResolver.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyReferenceResolver.cs @@ -269,7 +269,10 @@ internal static async Task> CreateAsmdefDirectoryMapA internal static bool TryReadJsonObjectFromFile(string filePath, out JObject jsonObject) { - return TryReadJsonObjectForMigration(filePath, File.ReadAllText, out jsonObject); + return TryReadJsonObjectForMigration( + filePath, + ThirdPartyToolMigrationFileAccess.ReadAllText, + out jsonObject); } internal static bool TryReadJsonObjectFromCache( diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs index 10e04c4063..aa6b05061d 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationAssemblyUsageAnalyzer.cs @@ -60,7 +60,7 @@ internal static MigrationAssemblyUsage FindMigrationAssemblyUsage( foreach (string csharpFilePath in csharpFilePaths) { - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); sourceByCSharpFilePath.Add(csharpFilePath, source); if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastAssemblyRequirementCollector.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastAssemblyRequirementCollector.cs index 2c74c93001..f228ade261 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastAssemblyRequirementCollector.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastAssemblyRequirementCollector.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Threading; using System.Threading.Tasks; @@ -166,7 +165,7 @@ internal static async Task CollectFastAssemblyReferenceRequirementsAsync( return; } - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); inspectedEntryCount++; if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) { @@ -250,7 +249,7 @@ internal static async Task CollectFastAssemblyScopedCurrentToolContractsRequirem return; } - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); inspectedEntryCount++; if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) { @@ -317,7 +316,7 @@ internal static async Task CollectFastAssemblyScopedCurrentDomainRequirementsAsy return; } - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); inspectedEntryCount++; if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) { @@ -391,7 +390,7 @@ internal static async Task CollectFastFirstPartyScreenshotRequirementsAsyn return false; } - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); inspectedEntryCount++; if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastSourceTargetDetector.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastSourceTargetDetector.cs index 2ed103c8dc..ae74a5cd05 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastSourceTargetDetector.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFastSourceTargetDetector.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Threading; using System.Threading.Tasks; @@ -89,7 +88,7 @@ internal static async Task ContainsFastCSharpSourceMigrationTargetAsync( return false; } - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); inspectedEntryCount++; if (inspectedEntryCount % ThirdPartyToolMigrationFileServiceConstants.PreviewYieldBatchSize == 0) { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileServiceConstants.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileServiceConstants.cs index 2a3bdb7414..e7194b6b5f 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileServiceConstants.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileServiceConstants.cs @@ -5,6 +5,10 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure /// internal static class ThirdPartyToolMigrationFileServiceConstants { + internal const int WindowsLegacyMaxPathLength = 260; + internal const string WindowsExtendedLengthPathPrefix = @"\\?\"; + internal const string WindowsExtendedLengthUncPathPrefix = @"\\?\UNC\"; + internal const string WindowsUncPathPrefix = @"\\"; internal const string ImplicitEditorAssemblyDirectoryName = "__UnityCliLoopImplicitEditorAssembly"; internal const string ImplicitRuntimeAssemblyDirectoryName = "__UnityCliLoopImplicitRuntimeAssembly"; internal const string ImplicitFirstPassEditorAssemblyDirectoryName = diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs index c332c3654a..42673fd3de 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -15,16 +16,16 @@ internal static void Write(string filePath, string content) Debug.Assert(content != null, "content must not be null"); string tempFilePath = CreateUniqueSidecarPath(filePath, ".tmp"); - File.WriteAllText(tempFilePath, content); - if (!File.Exists(filePath)) + ThirdPartyToolMigrationFileAccess.WriteAllText(tempFilePath, content); + if (!ThirdPartyToolMigrationFileAccess.Exists(filePath)) { - File.Move(tempFilePath, filePath); + ThirdPartyToolMigrationFileAccess.Move(tempFilePath, filePath); return; } string backupFilePath = CreateUniqueSidecarPath(filePath, ".bak"); - File.Replace(tempFilePath, filePath, backupFilePath); - File.Delete(backupFilePath); + ThirdPartyToolMigrationFileAccess.Replace(tempFilePath, filePath, backupFilePath); + ThirdPartyToolMigrationFileAccess.Delete(backupFilePath); } internal static string CreateUniqueSidecarPath(string filePath, string extension) @@ -35,7 +36,7 @@ internal static string CreateUniqueSidecarPath(string filePath, string extension string directory = Path.GetDirectoryName(filePath) ?? string.Empty; string fileName = Path.GetFileName(filePath); string sidecarPath = Path.Combine(directory, $"{fileName}.{Guid.NewGuid():N}{extension}"); - while (File.Exists(sidecarPath)) + while (ThirdPartyToolMigrationFileAccess.Exists(sidecarPath)) { sidecarPath = Path.Combine(directory, $"{fileName}.{Guid.NewGuid():N}{extension}"); } @@ -43,4 +44,102 @@ internal static string CreateUniqueSidecarPath(string filePath, string extension return sidecarPath; } } + + /// + /// Routes migration file IO through Windows extended-length paths when needed. + /// + internal static class ThirdPartyToolMigrationFileAccess + { + internal static string ReadAllText(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + return File.ReadAllText(GetFileSystemPath(filePath)); + } + + internal static void WriteAllText(string filePath, string content) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(content != null, "content must not be null"); + + File.WriteAllText(GetFileSystemPath(filePath), content); + } + + internal static IEnumerable ReadLines(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + return File.ReadLines(GetFileSystemPath(filePath)); + } + + internal static bool Exists(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + return File.Exists(GetFileSystemPath(filePath)); + } + + internal static void Move(string sourceFilePath, string destinationFilePath) + { + Debug.Assert(!string.IsNullOrEmpty(sourceFilePath), "sourceFilePath must not be null or empty"); + Debug.Assert( + !string.IsNullOrEmpty(destinationFilePath), + "destinationFilePath must not be null or empty"); + + File.Move(GetFileSystemPath(sourceFilePath), GetFileSystemPath(destinationFilePath)); + } + + internal static void Replace( + string sourceFilePath, + string destinationFilePath, + string destinationBackupFilePath) + { + Debug.Assert(!string.IsNullOrEmpty(sourceFilePath), "sourceFilePath must not be null or empty"); + Debug.Assert( + !string.IsNullOrEmpty(destinationFilePath), + "destinationFilePath must not be null or empty"); + Debug.Assert( + !string.IsNullOrEmpty(destinationBackupFilePath), + "destinationBackupFilePath must not be null or empty"); + + File.Replace( + GetFileSystemPath(sourceFilePath), + GetFileSystemPath(destinationFilePath), + GetFileSystemPath(destinationBackupFilePath)); + } + + internal static void Delete(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + File.Delete(GetFileSystemPath(filePath)); + } + + private static string GetFileSystemPath(string path) + { + Debug.Assert(!string.IsNullOrEmpty(path), "path must not be null or empty"); + + string fullPath = Path.GetFullPath(path); + if (Path.DirectorySeparatorChar != '\\' || + fullPath.Length < ThirdPartyToolMigrationFileServiceConstants.WindowsLegacyMaxPathLength || + fullPath.StartsWith( + ThirdPartyToolMigrationFileServiceConstants.WindowsExtendedLengthPathPrefix, + StringComparison.Ordinal)) + { + return fullPath; + } + + // Windows Mono still needs the extended-length prefix for file IO beyond legacy MAX_PATH. + if (fullPath.StartsWith( + ThirdPartyToolMigrationFileServiceConstants.WindowsUncPathPrefix, + StringComparison.Ordinal)) + { + return ThirdPartyToolMigrationFileServiceConstants.WindowsExtendedLengthUncPathPrefix + + fullPath.Substring( + ThirdPartyToolMigrationFileServiceConstants.WindowsUncPathPrefix.Length); + } + + return ThirdPartyToolMigrationFileServiceConstants.WindowsExtendedLengthPathPrefix + fullPath; + } + } } diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs index a02d7a073f..580b9ec1c1 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPlanBuilder.cs @@ -42,7 +42,7 @@ internal static MigrationPlan Create(string projectRoot) foreach (string csharpFilePath in inventory.CSharpFilePaths) { - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source) && !ThirdPartyToolMigrationRules.ContainsLegacyTypeAliasReference(source, legacyToolInfoAliases)) { @@ -140,7 +140,7 @@ internal static MigrationPlan Create(string projectRoot) assemblyUsage, changes, removedPlayerLoopTimingSignaturesByAssemblyDirectory, - File.ReadAllText); + ThirdPartyToolMigrationFileAccess.ReadAllText); foreach (string asmdefFilePath in inventory.AsmdefFilePaths) { @@ -158,7 +158,7 @@ internal static MigrationPlan Create(string projectRoot) out requiresApplicationReference, out requiresDomainReference, out requiresFirstPartyScreenshotReference); - string source = File.ReadAllText(asmdefFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(asmdefFilePath); if (!hasAssemblyMigrationRequirement && !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source)) { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs index 6ac6875c8b..52cfbd8e86 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationPreflightScanner.cs @@ -186,7 +186,7 @@ private static (bool WasRead, string Source) TryReadSourceText(string filePath) try { - return (true, File.ReadAllText(filePath)); + return (true, ThirdPartyToolMigrationFileAccess.ReadAllText(filePath)); } catch (Exception ex) when (IsSkippablePreflightReadException(ex)) { diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationSourceFileCache.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationSourceFileCache.cs index d145f2bf3d..392995c263 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationSourceFileCache.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationSourceFileCache.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; namespace io.github.hatayama.UnityCliLoop.Infrastructure { @@ -14,7 +13,7 @@ internal sealed class ThirdPartyToolMigrationSourceFileCache private readonly Dictionary _sources = new(StringComparer.Ordinal); internal ThirdPartyToolMigrationSourceFileCache() - : this(File.ReadAllText) + : this(ThirdPartyToolMigrationFileAccess.ReadAllText) { } diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs index e73daee4cb..b09b1af6b9 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationTargetScanner.cs @@ -84,7 +84,7 @@ internal static async Task HasMigrationTargetAsync(string projectRoot, Can return false; } - string source = File.ReadAllText(csharpFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(csharpFilePath); if (ContainsFastCSharpMigrationTarget(source)) { return true; @@ -320,7 +320,7 @@ await CollectFastAssemblyScopedCurrentDomainRequirementsAsync( return false; } - string source = File.ReadAllText(asmdefFilePath); + string source = ThirdPartyToolMigrationFileAccess.ReadAllText(asmdefFilePath); if (ContainsFastAsmdefMigrationTarget(source)) { return true; From 28abbb096b38f61d301f01775b66e134134cf551 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 25 Jun 2026 01:45:36 +0900 Subject: [PATCH 2/2] Fix migration fingerprinting for long Windows paths Use the migration long-path file access wrapper for cached plan fingerprint metadata and content hashes so edits to long-path source files invalidate preview plans correctly. Add Windows-only regression coverage for long-path discovery and fingerprint changes. --- ...ThirdPartyToolMigrationFileServiceTests.cs | 97 +++++++++++++++++++ .../ThirdPartyToolMigrationFileWriter.cs | 22 +++++ .../ThirdPartyToolMigrationModels.cs | 9 +- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs index 9b6d31ef0e..d76c52e550 100644 --- a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -146,6 +146,88 @@ await service.ApplyMigrationAsync( } } + [Test] + [Platform(Include = "Win")] + public async Task ApplyMigrationAsync_WhenWindowsDirectoryPathExceedsLegacyLimit_MigratesSource() + { + // Verifies that migration apply can discover source files in directories beyond the legacy Windows path limit. + string projectRoot = CreateProjectRoot(); + try + { + string longDirectoryPath = CreateVeryLongDirectoryPath(projectRoot); + string filePath = Path.Combine(longDirectoryPath, "LongDirectoryTool.cs"); + Directory.CreateDirectory(ToWindowsExtendedLengthPath(longDirectoryPath)); + File.WriteAllText( + ToWindowsExtendedLengthPath(filePath), + @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class LongDirectoryTool : AbstractUnityTool +{ +} + +public sealed class LongDirectorySchema : BaseToolSchema +{ +} + +public sealed class LongDirectoryResponse : BaseToolResponse +{ +}"); + + ThirdPartyToolMigrationFileService service = new(); + + ThirdPartyToolMigrationResult result = + await service.ApplyMigrationAsync( + projectRoot, + new Progress(), + CancellationToken.None); + string migratedSource = File.ReadAllText(ToWindowsExtendedLengthPath(filePath)); + + Assert.That(longDirectoryPath.Length, Is.GreaterThanOrEqualTo(WindowsLegacyMaxPathLength)); + Assert.That(result.FilePaths.Contains(filePath), Is.True); + Assert.That( + migratedSource, + Does.Contain("UnityCliLoopTool")); + } + finally + { + Directory.Delete(ToWindowsExtendedLengthPath(projectRoot), recursive: true); + } + } + + [Test] + [Platform(Include = "Win")] + public void MigrationProjectFingerprint_WhenWindowsPathExceedsLegacyLimit_DetectsContentChange() + { + // Verifies that long-path source edits invalidate cached migration plans. + string projectRoot = CreateProjectRoot(); + try + { + string longDirectoryPath = CreateLongDirectoryPath(projectRoot); + string filePath = Path.Combine(longDirectoryPath, "LongPathTool.cs"); + string originalSource = "public sealed class AlphaTool {}"; + string changedSource = "public sealed class BravoTool {}"; + DateTime originalLastWriteTimeUtc = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Directory.CreateDirectory(ToWindowsExtendedLengthPath(longDirectoryPath)); + File.WriteAllText(ToWindowsExtendedLengthPath(filePath), originalSource); + File.SetLastWriteTimeUtc(ToWindowsExtendedLengthPath(filePath), originalLastWriteTimeUtc); + ProjectFileInventory firstInventory = ProjectFileInventory.Create(projectRoot); + MigrationProjectFingerprint fingerprint = + MigrationProjectFingerprint.CaptureFromInventory(firstInventory); + + File.WriteAllText(ToWindowsExtendedLengthPath(filePath), changedSource); + File.SetLastWriteTimeUtc(ToWindowsExtendedLengthPath(filePath), originalLastWriteTimeUtc); + ProjectFileInventory changedInventory = ProjectFileInventory.Create(projectRoot); + + Assert.That(changedSource.Length, Is.EqualTo(originalSource.Length)); + Assert.That(fingerprint.Matches(changedInventory), Is.False); + } + finally + { + Directory.Delete(ToWindowsExtendedLengthPath(projectRoot), recursive: true); + } + } + [Test] public void MigrationProjectFingerprint_WhenCandidateFileChanges_DoesNotMatch() { @@ -5563,6 +5645,21 @@ private static string CreateLongDirectoryPath(string projectRoot) return directoryPath; } + private static string CreateVeryLongDirectoryPath(string projectRoot) + { + Assert.That(projectRoot, Is.Not.Empty); + + string directoryPath = Path.Combine(projectRoot, "Assets"); + int segmentIndex = 0; + while (directoryPath.Length < WindowsLegacyMaxPathLength) + { + directoryPath = Path.Combine(directoryPath, "LongDirectorySegment" + segmentIndex.ToString("D2")); + segmentIndex++; + } + + return directoryPath; + } + private static string ToWindowsExtendedLengthPath(string path) { Assert.That(path, Is.Not.Empty); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs index 42673fd3de..9cd2875ecd 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileWriter.cs @@ -79,6 +79,28 @@ internal static bool Exists(string filePath) return File.Exists(GetFileSystemPath(filePath)); } + internal static long GetLength(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + FileInfo fileInfo = new(GetFileSystemPath(filePath)); + return fileInfo.Length; + } + + internal static DateTime GetLastWriteTimeUtc(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + return File.GetLastWriteTimeUtc(GetFileSystemPath(filePath)); + } + + internal static FileStream OpenRead(string filePath) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + + return File.OpenRead(GetFileSystemPath(filePath)); + } + internal static void Move(string sourceFilePath, string destinationFilePath) { Debug.Assert(!string.IsNullOrEmpty(sourceFilePath), "sourceFilePath must not be null or empty"); diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs index 0e2be23e9f..ebaca4997b 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationModels.cs @@ -162,8 +162,7 @@ public static MigrationFileFingerprint Capture(string filePath) { Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); - FileInfo fileInfo = new(filePath); - if (!fileInfo.Exists) + if (!ThirdPartyToolMigrationFileAccess.Exists(filePath)) { return new MigrationFileFingerprint( filePath, @@ -176,8 +175,8 @@ public static MigrationFileFingerprint Capture(string filePath) return new MigrationFileFingerprint( filePath, true, - fileInfo.Length, - fileInfo.LastWriteTimeUtc.Ticks, + ThirdPartyToolMigrationFileAccess.GetLength(filePath), + ThirdPartyToolMigrationFileAccess.GetLastWriteTimeUtc(filePath).Ticks, CaptureContentHash(filePath)); } @@ -210,7 +209,7 @@ private static ulong CaptureReadableContentHash(string filePath) byte[] buffer = new byte[ContentHashBufferSize]; ulong contentHash = ContentHashOffsetBasis; - using FileStream stream = File.OpenRead(filePath); + using FileStream stream = ThirdPartyToolMigrationFileAccess.OpenRead(filePath); while (true) { int readByteCount = stream.Read(buffer, 0, buffer.Length);