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");