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,