diff --git a/Assets/Tests/Editor/CompileErrorNextActionsComposerTests.cs b/Assets/Tests/Editor/CompileErrorNextActionsComposerTests.cs new file mode 100644 index 000000000..dd6146d20 --- /dev/null +++ b/Assets/Tests/Editor/CompileErrorNextActionsComposerTests.cs @@ -0,0 +1,335 @@ +using System; +using NUnit.Framework; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests language-version NextAction detection, wording, append behavior, and factory wiring. + /// + [TestFixture] + public sealed class CompileErrorNextActionsComposerTests + { + private const string FileScopedNamespaceError = + "error CS8370: Feature 'file-scoped namespace' is not available in C# 9.0. Please use language version 10.0 or greater."; + + private const string PrefixlessFileScopedNamespaceError = + "CS8370: Feature 'file-scoped namespace' is not available in C# 9.0. Please use language version 10.0 or greater."; + + private const string FileScopedNamespaceNextAction = + "error CS8370: the project's C# language version is pinned by the Unity Editor version, so raising the language version is not actionable here. Rewrite without the 'file-scoped namespace' feature so the code compiles under C# 9.0."; + + private const string RecordsError = + "error CS8400: Feature 'records' is not available in C# 8.0. Please use language version 9.0 or greater."; + + private const string RecordsNextAction = + "error CS8400: the project's C# language version is pinned by the Unity Editor version, so raising the language version is not actionable here. Rewrite without the 'records' feature so the code compiles under C# 8.0."; + + private const string RequiredMembersError = + "error CS8652: Feature 'required members' is not available in C# 10. Please use language version 11.0 or greater."; + + private const string RequiredMembersNextAction = + "error CS8652: the project's C# language version is pinned by the Unity Editor version, so raising the language version is not actionable here. Rewrite without the 'required members' feature so the code compiles under C# 10."; + + private const string RawStringLiteralsError = + "error CS8936: Feature 'raw string literals' is not available in C# 10.0. Please use language version 11.0 or greater."; + + private const string RawStringLiteralsNextAction = + "error CS8936: the project's C# language version is pinned by the Unity Editor version, so raising the language version is not actionable here. Rewrite without the 'raw string literals' feature so the code compiles under C# 10.0."; + + private const string UnrelatedError = "error CS0000: sample compile error"; + + private const string ExistingNextAction = + "Wait for domain reload to complete, then run `uloop compile` without --force-recompile to obtain a definitive result."; + + private const string ApiUpdaterNextAction = + "Fix the obsolete API usages reported in Errors, or ask the user to accept the Script Updating Consent dialog in an interactive Unity session."; + + /// + /// What: a language-version error produces the pinned-rewrite NextAction as an exact literal. + /// + [Test] + public void Build_WhenLanguageVersionError_ReturnsPinnedRewriteAction() + { + string[] nextActions = CompileErrorNextActionsBuilder.Build(new[] { FileScopedNamespaceError }); + + Assert.That(nextActions, Is.EqualTo(new[] { FileScopedNamespaceNextAction })); + } + + /// + /// What: a prefix-less CS#### message still produces the same pinned-rewrite NextAction. + /// + [Test] + public void Build_WhenPrefixlessErrorCode_ReturnsPinnedRewriteAction() + { + string[] nextActions = CompileErrorNextActionsBuilder.Build(new[] { PrefixlessFileScopedNamespaceError }); + + Assert.That(nextActions, Is.EqualTo(new[] { FileScopedNamespaceNextAction })); + } + + /// + /// What: unmatched error messages produce no NextActions. + /// + [Test] + public void Build_WhenNoMatch_ReturnsEmpty() + { + string[] nextActions = CompileErrorNextActionsBuilder.Build(new[] { UnrelatedError }); + + Assert.That(nextActions, Is.EqualTo(Array.Empty())); + } + + /// + /// What: a language-version sentence without a CS#### code is skipped (fail-open). + /// + [Test] + public void Build_WhenMessageHasNoErrorCode_ReturnsEmpty() + { + string[] nextActions = CompileErrorNextActionsBuilder.Build( + new[] { "Feature 'file-scoped namespace' is not available in C# 9.0." }); + + Assert.That(nextActions, Is.EqualTo(Array.Empty())); + } + + /// + /// What: identical generated NextActions are appended only once. + /// + [Test] + public void Build_WhenDuplicateGeneratedActions_Dedups() + { + string[] nextActions = CompileErrorNextActionsBuilder.Build( + new[] { FileScopedNamespaceError, PrefixlessFileScopedNamespaceError }); + + Assert.That(nextActions, Is.EqualTo(new[] { FileScopedNamespaceNextAction })); + } + + /// + /// What: at most three generated NextActions are returned even when more messages match. + /// + [Test] + public void Build_WhenMoreThanThreeMatches_ReturnsAtMostThree() + { + string[] nextActions = CompileErrorNextActionsBuilder.Build( + new[] + { + FileScopedNamespaceError, + RecordsError, + RequiredMembersError, + RawStringLiteralsError + }); + + Assert.That( + nextActions, + Is.EqualTo(new[] + { + FileScopedNamespaceNextAction, + RecordsNextAction, + RequiredMembersNextAction + })); + } + + /// + /// What: only the first ten error messages are scanned for NextAction generation. + /// + [Test] + public void Build_WhenMatchIsAfterFirstTenMessages_ReturnsEmpty() + { + string[] errorMessages = new string[11]; + for (int index = 0; index < 10; index++) + { + errorMessages[index] = UnrelatedError; + } + + errorMessages[10] = FileScopedNamespaceError; + + string[] nextActions = CompileErrorNextActionsBuilder.Build(errorMessages); + + Assert.That(nextActions, Is.EqualTo(Array.Empty())); + } + + /// + /// What: a successful compile leaves NextActions unchanged even when errors look matchable. + /// + [Test] + public void Apply_WhenSuccess_LeavesNextActionsUnchanged() + { + CompileResponse response = CreateResponse(success: true); + response.NextActions = new[] { ExistingNextAction }; + + CompileErrorNextActionsComposer.Apply(response, new[] { CreateError(FileScopedNamespaceError) }); + + Assert.That(response.NextActions, Is.EqualTo(new[] { ExistingNextAction })); + } + + /// + /// What: a null error list leaves NextActions unchanged. + /// + [Test] + public void Apply_WhenErrorsNull_LeavesNextActionsUnchanged() + { + CompileResponse response = CreateResponse(success: false); + response.NextActions = new[] { ExistingNextAction }; + + CompileErrorNextActionsComposer.Apply(response, errors: null); + + Assert.That(response.NextActions, Is.EqualTo(new[] { ExistingNextAction })); + } + + /// + /// What: unmatched errors leave existing NextActions unchanged. + /// + [Test] + public void Apply_WhenNoMatch_LeavesNextActionsUnchanged() + { + CompileResponse response = CreateResponse(success: false); + response.NextActions = new[] { ExistingNextAction }; + + CompileErrorNextActionsComposer.Apply(response, new[] { CreateError(UnrelatedError) }); + + Assert.That(response.NextActions, Is.EqualTo(new[] { ExistingNextAction })); + } + + /// + /// What: existing NextActions are kept and the language-version action is appended at the end. + /// + [Test] + public void Apply_WhenExistingNextActions_AppendsLanguageVersionAction() + { + CompileResponse response = CreateResponse(success: false); + response.NextActions = new[] { ExistingNextAction }; + + CompileErrorNextActionsComposer.Apply(response, new[] { CreateError(FileScopedNamespaceError) }); + + Assert.That( + response.NextActions, + Is.EqualTo(new[] { ExistingNextAction, FileScopedNamespaceNextAction })); + } + + /// + /// What: CreateResponse emits the language-version NextAction as the entire NextActions array. + /// + [Test] + public void CreateResponse_WhenLanguageVersionError_ReturnsExactNextActions() + { + CompileResult result = CreateFailedResult(CreateError(FileScopedNamespaceError)); + + CompileResponse response = CompileResponseFactory.CreateResponse( + result, + forceRecompile: false, + pausePointWarning: null); + + Assert.That(response.NextActions, Is.EqualTo(new[] { FileScopedNamespaceNextAction })); + } + + /// + /// What: a determinate force-compile result keeps only the wait NextAction even when errors match. + /// + [Test] + public void CreateResponse_WhenForceCompileWithLanguageVersionError_DoesNotAddRewriteAction() + { + CompileResult result = new CompileResult( + success: false, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: new[] { CreateError(FileScopedNamespaceError) }, + errors: new[] { CreateError(FileScopedNamespaceError) }, + warnings: Array.Empty(), + isIndeterminate: false, + message: null); + + CompileResponse response = CompileResponseFactory.CreateResponse( + result, + forceRecompile: true, + pausePointWarning: null); + + Assert.That(response.NextActions, Is.EqualTo(new[] { ExistingNextAction })); + } + + /// + /// What: indeterminate non-force results do not append a language-version rewrite NextAction. + /// + [Test] + public void CreateResponse_WhenIndeterminateWithLanguageVersionError_DoesNotAddRewriteAction() + { + CompileResult result = new CompileResult( + success: null, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: new[] { CreateError(FileScopedNamespaceError) }, + errors: new[] { CreateError(FileScopedNamespaceError) }, + warnings: Array.Empty(), + isIndeterminate: true, + message: null); + + CompileResponse response = CompileResponseFactory.CreateResponse( + result, + forceRecompile: false, + pausePointWarning: null); + + Assert.That(response.NextActions, Is.Null); + } + + /// + /// What: CreateResponse appends the language-version NextAction after the API Updater action. + /// + [Test] + public void CreateResponse_WhenLanguageVersionErrorAndConsentDeclined_AppendsAfterExistingNextActions() + { + CompileResult result = new CompileResult( + success: false, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: Array.Empty(), + errors: new[] { CreateError(FileScopedNamespaceError) }, + warnings: Array.Empty(), + apiUpdaterConsentDeclined: true); + + CompileResponse response = CompileResponseFactory.CreateResponse( + result, + forceRecompile: false, + pausePointWarning: null); + + Assert.That( + response.NextActions, + Is.EqualTo(new[] { ApiUpdaterNextAction, FileScopedNamespaceNextAction })); + } + + private static CompileResponse CreateResponse(bool success) + { + return new CompileResponse( + success: success, + errorCount: success ? 0 : 1, + warningCount: 0, + errors: null, + warnings: null, + message: null); + } + + private static CompileResult CreateFailedResult(CompilerMessage error) + { + return new CompileResult( + success: false, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: new[] { error }, + errors: new[] { error }, + warnings: Array.Empty()); + } + + private static CompilerMessage CreateError(string message) + { + return new CompilerMessage + { + type = CompilerMessageType.Error, + message = message, + file = "Assets/Sample.cs", + line = 1 + }; + } + } +} diff --git a/Assets/Tests/Editor/CompileErrorNextActionsComposerTests.cs.meta b/Assets/Tests/Editor/CompileErrorNextActionsComposerTests.cs.meta new file mode 100644 index 000000000..7fc5fcd77 --- /dev/null +++ b/Assets/Tests/Editor/CompileErrorNextActionsComposerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 047b602ab4ea146688575acd509f3b9e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsBuilder.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsBuilder.cs new file mode 100644 index 000000000..f7961ee0e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsBuilder.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds compile-error NextActions from raw compiler error messages without depending on CompilerMessage. + /// + internal static class CompileErrorNextActionsBuilder + { + private static readonly Regex ErrorCodeRegex = new Regex( + CompileErrorNextActionsConstants.ErrorCodePattern, + RegexOptions.CultureInvariant); + + private static readonly Regex LanguageVersionFeatureRegex = new Regex( + CompileErrorNextActionsConstants.LanguageVersionFeaturePattern, + RegexOptions.CultureInvariant); + + /// + /// Returns up to three deduplicated NextActions for the first ten error messages. + /// + internal static string[] Build(string[] errorMessages) + { + if (errorMessages == null) + { + return Array.Empty(); + } + + List additions = new List(); + int scanCount = Math.Min(errorMessages.Length, CompileErrorNextActionsConstants.MaxErrorsToScan); + for (int index = 0; index < scanCount; index++) + { + if (additions.Count >= CompileErrorNextActionsConstants.MaxNextActionsToAppend) + { + break; + } + + string nextAction = TryBuildLanguageVersionNextAction(errorMessages[index]); + if (nextAction == null) + { + continue; + } + + if (additions.Contains(nextAction)) + { + continue; + } + + additions.Add(nextAction); + } + + return additions.ToArray(); + } + + /// + /// Why: Roslyn's "use language version N or greater" suggestion is a dead end in Unity + /// because the language version is fixed by the Editor version; without this correction + /// agents attempt langversion changes. + /// + private static string TryBuildLanguageVersionNextAction(string message) + { + if (string.IsNullOrEmpty(message)) + { + return null; + } + + Match errorCodeMatch = ErrorCodeRegex.Match(message); + if (!errorCodeMatch.Success) + { + return null; + } + + Match featureMatch = LanguageVersionFeatureRegex.Match(message); + if (!featureMatch.Success) + { + return null; + } + + return string.Format( + CompileErrorNextActionsConstants.LanguageVersionPinnedNextActionFormat, + errorCodeMatch.Groups[1].Value, + featureMatch.Groups["feature"].Value, + featureMatch.Groups["version"].Value); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsBuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsBuilder.cs.meta new file mode 100644 index 000000000..88680e733 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cb6634dbbb0bf4edebb45aa4dd3ac77a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsComposer.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsComposer.cs new file mode 100644 index 000000000..fff2a7439 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsComposer.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using UnityEditor.Compilation; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Appends error-origin NextActions onto a failed CompileResponse without replacing existing actions. + /// + internal static class CompileErrorNextActionsComposer + { + /// + /// Why not replace NextActions: existing recovery steps such as API Updater consent must stay, + /// and unmatched errors must remain fail-open. + /// + internal static void Apply(CompileResponse response, CompilerMessage[] errors) + { + Debug.Assert(response != null, "response must not be null"); + if (response.Success) + { + return; + } + + if (errors == null) + { + return; + } + + string[] messages = new string[errors.Length]; + for (int index = 0; index < errors.Length; index++) + { + messages[index] = errors[index].message; + } + + string[] additions = CompileErrorNextActionsBuilder.Build(messages); + if (additions.Length == 0) + { + return; + } + + response.NextActions = Append(response.NextActions, additions); + } + + private static string[] Append(string[] existing, string[] additions) + { + if (existing == null || existing.Length == 0) + { + return additions; + } + + string[] merged = new string[existing.Length + additions.Length]; + for (int index = 0; index < existing.Length; index++) + { + merged[index] = existing[index]; + } + + for (int index = 0; index < additions.Length; index++) + { + merged[existing.Length + index] = additions[index]; + } + + return merged; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsComposer.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsComposer.cs.meta new file mode 100644 index 000000000..89d124c9c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsComposer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 18f225c3e4626434bb2ef71672f007a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsConstants.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsConstants.cs new file mode 100644 index 000000000..3d653debb --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsConstants.cs @@ -0,0 +1,20 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Literals used to detect compile-error origins and append corrective NextActions. + /// + internal static class CompileErrorNextActionsConstants + { + public const string LanguageVersionPinnedNextActionFormat = + "error {0}: the project's C# language version is pinned by the Unity Editor version, so raising the language version is not actionable here. Rewrite without the '{1}' feature so the code compiles under C# {2}."; + + public const string ErrorCodePattern = @"\b(CS[0-9]{4})\b"; + + public const string LanguageVersionFeaturePattern = + @"Feature '(?[^']+)' is not available in C# (?[0-9]+(\.[0-9]+)?)"; + + public const int MaxErrorsToScan = 10; + + public const int MaxNextActionsToAppend = 3; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsConstants.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsConstants.cs.meta new file mode 100644 index 000000000..9e3982de9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileErrorNextActionsConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f26a9e1ba26ed4d63b5f0b6e12ff1f06 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs index a4f068345..4d3b65667 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponseFactory.cs @@ -37,9 +37,33 @@ internal static CompileResponse CreateResponse( forceRecompile, pausePointWarning); CompileApiUpdaterConsentResponseComposer.Apply(response, result.ApiUpdaterConsentDeclined); + if (ShouldApplyErrorNextActions(result, forceRecompile)) + { + CompileErrorNextActionsComposer.Apply(response, result.Errors); + } + return response; } + /// + /// Why: force-compile and indeterminate results withhold reliable issue lists. + /// Appending a rewrite action from those errors would misdirect agents again. + /// + private static bool ShouldApplyErrorNextActions(CompileResult result, bool forceRecompile) + { + if (result.IsIndeterminate) + { + return false; + } + + if (forceRecompile && !result.PreserveDetailsWhenForceRecompile) + { + return false; + } + + return true; + } + private static CompileResponse CreateResponseWithoutApiUpdaterConsent( CompileResult result, bool forceRecompile,