diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs new file mode 100644 index 0000000000..9523df6238 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies compile-error-log entries are matched against V2 legacy API tokens + /// and reduced to a deduplicated set of migration target file paths. + /// + public sealed class ThirdPartyToolMigrationCompileErrorLogMatcherTests + { + [Test] + public void Match_WhenNoEntryContainsLegacyToken_ReturnsEmptyResult() + { + // Verifies that unrelated V3 compile errors produce no matched entries and no target file paths. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(1,1): error CS0103: The name 'undefinedSymbol' does not exist in the current context", + "Assets/Editor/MyTool.cs", + 1) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries, Is.Empty); + Assert.That(result.TargetFilePaths, Is.Empty); + } + + [Test] + public void Match_WhenEntryContainsLegacyToken_ReturnsMatchedEntryAndTargetFilePath() + { + // Verifies that an entry whose message contains a legacy API token is included in both + // the matched-entries list and the target file path list. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(3,25): error CS0234: The type or namespace name 'uLoopMCP' " + + "does not exist in the namespace 'io.github.hatayama' (are you missing an assembly reference?)", + "Assets/Editor/MyTool.cs", + 3) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries.Count, Is.EqualTo(1)); + Assert.That(result.TargetFilePaths, Is.EqualTo(new[] { "Assets/Editor/MyTool.cs" })); + } + + [Test] + public void Match_WhenMultipleEntriesShareTheSameFilePath_DeduplicatesTargetFilePaths() + { + // Verifies that multiple legacy-token-matching errors in the same file collapse to one target path. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(3,25): error CS0234: The type or namespace name 'uLoopMCP' " + + "does not exist in the namespace 'io.github.hatayama' (are you missing an assembly reference?)", + "Assets/Editor/MyTool.cs", + 3), + new CompileErrorLogEntry( + "Assets/Editor/MyTool.cs(9,14): error CS0246: The type or namespace name 'AbstractUnityTool' " + + "could not be found (are you missing a using directive or an assembly reference?)", + "Assets/Editor/MyTool.cs", + 9) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries.Count, Is.EqualTo(2)); + Assert.That(result.TargetFilePaths, Is.EqualTo(new[] { "Assets/Editor/MyTool.cs" })); + } + + [Test] + public void Match_WhenEntriesMixMatchingAndNonMatching_OnlyIncludesMatchingFilePaths() + { + // Verifies that a non-matching entry does not contribute its file path even when it is + // interleaved with matching entries from other files. + List entries = new List + { + new CompileErrorLogEntry( + "Assets/Editor/Unrelated.cs(1,1): error CS0103: The name 'undefinedSymbol' does not exist in the current context", + "Assets/Editor/Unrelated.cs", + 1), + new CompileErrorLogEntry( + "Assets/Editor/LegacyTool.cs(2,6): error CS0246: The type or namespace name 'McpTool' " + + "could not be found (are you missing a using directive or an assembly reference?)", + "Assets/Editor/LegacyTool.cs", + 2) + }; + + ThirdPartyToolMigrationCompileErrorLogMatchResult result = + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(entries); + + Assert.That(result.MatchedEntries.Count, Is.EqualTo(1)); + Assert.That(result.TargetFilePaths, Is.EqualTo(new[] { "Assets/Editor/LegacyTool.cs" })); + } + + [Test] + public void Match_WhenEntriesIsNull_Throws() + { + // Verifies fail-fast behavior when the entry collection itself is missing. + Assert.Throws(() => + ThirdPartyToolMigrationCompileErrorLogMatcher.Match(null)); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs.meta new file mode 100644 index 0000000000..42851f407d --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationCompileErrorLogMatcherTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 48981f198be2842188e338ec04026f34 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs new file mode 100644 index 0000000000..e7b83045b7 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs @@ -0,0 +1,83 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies compile-error-message token matching for V2 legacy API detection. + /// + public sealed class ThirdPartyToolMigrationDetectionRulesTests + { + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsLegacyNamespaceSegment_ReturnsTrue() + { + // Verifies that a CS0234 message shaped like Roslyn's actual segment-only report is detected. + const string message = + "Assets/Editor/MyTool.cs(3,25): error CS0234: The type or namespace name 'uLoopMCP' " + + "does not exist in the namespace 'io.github.hatayama' (are you missing an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsLegacyAssemblyName_ReturnsTrue() + { + // Verifies that asmdef-reference-shaped errors referencing the legacy assembly name are detected. + const string message = "error: Assembly 'uLoopMCP.Editor' could not be resolved."; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsRenamedLegacyTypeName_ReturnsTrue() + { + // Verifies that CS0246-shaped errors for a renamed legacy base type are detected without depending on the error code. + const string message = "Assets/Editor/MyTool.cs(5,14): error CS0246: The type or namespace name " + + "'AbstractUnityTool' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageContainsAttributeSuffixStrippedForm_ReturnsTrue() + { + // Verifies that Roslyn's attribute-usage error, which reports 'McpTool' without the 'Attribute' suffix, is still detected. + const string message = "Assets/Editor/MyTool.cs(2,6): error CS0246: The type or namespace name " + + "'McpTool' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.True); + } + + [Test] + public void ContainsLegacyApiToken_WhenTokenIsSubstringOfUnrelatedIdentifier_ReturnsFalse() + { + // Verifies identifier-boundary matching: 'IUnityTool' must not match inside 'IUnityToolbarButton'. + const string message = "Assets/Editor/MyTool.cs(9,10): error CS0246: The type or namespace name " + + "'IUnityToolbarButton' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.False); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageIsUnrelatedV3CompileError_ReturnsFalse() + { + // Verifies that ordinary V3 compile errors unrelated to legacy migration do not match. + const string message = "Assets/Editor/MyTool.cs(1,1): error CS0103: The name 'undefinedSymbol' " + + "does not exist in the current context"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.False); + } + + [Test] + public void ContainsLegacyApiToken_WhenMessageReferencesTypeSharedByLegacyAndCurrentNames_ReturnsFalse() + { + // Verifies that names unchanged between V2 and V3 (e.g. ServiceResult) are excluded from the token set, + // since matching them would false-positive on unrelated V3-only compile errors. + const string message = "Assets/Editor/MyTool.cs(4,9): error CS0246: The type or namespace name " + + "'ServiceResult' could not be found (are you missing a using directive or an assembly reference?)"; + + Assert.That(ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(message), Is.False); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs.meta new file mode 100644 index 0000000000..74100b2411 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationDetectionRulesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1237101791612415e9fed477a7e29353 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs new file mode 100644 index 0000000000..f0c61e1aee --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// A single compile-error console log entry, already carrying the file path and line number the + /// caller resolved from the raw log (see ThirdPartyToolMigrationCompileErrorLogMatcher for why the + /// matcher itself does not parse these out of the message text). + /// + internal readonly struct CompileErrorLogEntry + { + public CompileErrorLogEntry(string message, string filePath, int lineNumber) + { + Debug.Assert(!string.IsNullOrEmpty(message), "message must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(lineNumber >= 0, "lineNumber must not be negative"); + + Message = message ?? throw new ArgumentNullException(nameof(message)); + FilePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + LineNumber = lineNumber; + } + + public string Message { get; } + public string FilePath { get; } + public int LineNumber { get; } + } + + /// + /// The result of matching compile-error log entries against V2 legacy API tokens: the matched + /// entries themselves, plus the deduplicated set of file paths they point to. + /// + internal readonly struct ThirdPartyToolMigrationCompileErrorLogMatchResult + { + public ThirdPartyToolMigrationCompileErrorLogMatchResult( + List matchedEntries, + List targetFilePaths) + { + Debug.Assert(matchedEntries != null, "matchedEntries must not be null"); + Debug.Assert(targetFilePaths != null, "targetFilePaths must not be null"); + + MatchedEntries = matchedEntries ?? throw new ArgumentNullException(nameof(matchedEntries)); + TargetFilePaths = targetFilePaths ?? throw new ArgumentNullException(nameof(targetFilePaths)); + } + + public List MatchedEntries { get; } + public List TargetFilePaths { get; } + } + + /// + /// Matches compile-error log entries against V2 legacy API tokens (see + /// ThirdPartyToolMigrationDetectionRules) to drive the compile-error-driven auto-scan trigger. + /// This is a pure filter/dedup step: parsing file path and line number out of raw console log text + /// is a separate concern owned by the caller wiring this to the real log source. + /// + internal static class ThirdPartyToolMigrationCompileErrorLogMatcher + { + internal static ThirdPartyToolMigrationCompileErrorLogMatchResult Match( + IReadOnlyList entries) + { + Debug.Assert(entries != null, "entries must not be null"); + if (entries == null) + { + throw new ArgumentNullException(nameof(entries)); + } + + List matchedEntries = new List(); + foreach (CompileErrorLogEntry entry in entries) + { + if (ThirdPartyToolMigrationDetectionRules.ContainsLegacyApiToken(entry.Message)) + { + matchedEntries.Add(entry); + } + } + + List targetFilePaths = matchedEntries + .Select(entry => entry.FilePath) + .Distinct(StringComparer.Ordinal) + .ToList(); + + return new ThirdPartyToolMigrationCompileErrorLogMatchResult(matchedEntries, targetFilePaths); + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs.meta new file mode 100644 index 0000000000..84d2291c54 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationCompileErrorLogMatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d0081bd2244cf4482874425f651a7ef2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs index 798e976080..488d82d5b7 100644 --- a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationDetectionRules.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using Newtonsoft.Json.Linq; +using TypeReplacementRule = io.github.hatayama.UnityCliLoop.Domain.ThirdPartyToolMigrationParsingRules.TypeReplacementRule; + using static io.github.hatayama.UnityCliLoop.Domain.ThirdPartyToolMigrationParsingRules; using static io.github.hatayama.UnityCliLoop.Domain.ThirdPartyToolMigrationRuleCatalog; @@ -9,6 +12,94 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { internal static class ThirdPartyToolMigrationDetectionRules { + private const string AttributeSuffix = "Attribute"; + + /// + /// Compile-error-message tokens that identify V2 legacy API usage. Derived from + /// ThirdPartyToolMigrationRuleCatalog (the single source for legacy/current names) rather than + /// duplicated here, and limited to names that actually changed between V2 and V3 so unrelated + /// V3-only compile errors do not false-positive on names that stayed the same (e.g. ServiceResult). + /// CustomToolManager is intentionally not included: every V2 file that references it also + /// contains a using/qualified reference to the legacy namespace, which already produces a + /// "uLoopMCP" token hit in the same file, so omitting it does not create a per-file detection gap. + /// + internal static readonly string[] LegacyApiTokens = BuildLegacyApiTokens(); + + private static string[] BuildLegacyApiTokens() + { + List tokens = new List + { + LegacyNamespace.Split('.')[^1] + }; + + foreach (TypeReplacementRule rule in ToolContractTypeReplacementRules) + { + if (string.Equals(rule.LegacyName, rule.CurrentName, StringComparison.Ordinal)) + { + continue; + } + + tokens.Add(rule.LegacyName); + + if (rule.LegacyName.EndsWith(AttributeSuffix, StringComparison.Ordinal)) + { + tokens.Add(rule.LegacyName[..^AttributeSuffix.Length]); + } + } + + return tokens.ToArray(); + } + + /// + /// Checks whether a compile-error message body contains any V2 legacy API token at an + /// identifier boundary. This is a fast, non-scanning shortcut for the auto-scan trigger only; + /// false positives are harmless because the assembly-scoped scan that follows is the source of + /// truth and simply finds zero targets when a match turns out to be unrelated. + /// + internal static bool ContainsLegacyApiToken(string message) + { + Debug.Assert(message != null, "message must not be null"); + + foreach (string token in LegacyApiTokens) + { + if (ContainsTokenAtIdentifierBoundary(message, token)) + { + return true; + } + } + + return false; + } + + private static bool ContainsTokenAtIdentifierBoundary(string text, string token) + { + int searchStart = 0; + while (true) + { + int matchIndex = text.IndexOf(token, searchStart, StringComparison.Ordinal); + if (matchIndex < 0) + { + return false; + } + + bool hasLeadingBoundary = matchIndex == 0 || !IsIdentifierCharacter(text[matchIndex - 1]); + int afterMatchIndex = matchIndex + token.Length; + bool hasTrailingBoundary = afterMatchIndex == text.Length || !IsIdentifierCharacter(text[afterMatchIndex]); + + if (hasLeadingBoundary && hasTrailingBoundary) + { + return true; + } + + searchStart = matchIndex + 1; + } + } + + private static bool IsIdentifierCharacter(char character) + { + return char.IsLetterOrDigit(character) || character == '_'; + } + internal static bool ContainsLegacyAsmdefNameReference(string source) { Debug.Assert(source != null, "source must not be null");