From 4c9069f3ddec4bab87979a19ea7fd45f48bd9ced Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 12:58:17 +0900 Subject: [PATCH 1/7] test: Add compiled abstract, extern, and generic abstract hot reload hosts The worker tests for bodyless members without a compiled-source baseline need real compiled types to classify against: an abstract method, an extern method, and an abstract method on a generic type (which the worker hard-skips before it looks at the body). Each host also carries a concrete method where the test needs to show the rest of the file is still patched. --- .../HotReload/HotReloadAddedMemberHost.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs index e52ea153d2..d31102eec0 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using UnityEngine; @@ -462,4 +463,44 @@ public int PartialRemoved() return 2; } } + + /// + /// Compiled abstract host so a bodyless abstract method can be classified against a real + /// compiled type when no compiled-source baseline exists. + /// + public abstract class HotReloadAddedMemberAbstractHost + { + public abstract int Shape(int value); + + [MethodImpl(MethodImplOptions.NoInlining)] + public int AbstractHostConcrete() + { + return 1; + } + } + + /// + /// Compiled host with an extern method so a bodyless extern method can be classified against + /// a real compiled type when no compiled-source baseline exists. The library is never loaded. + /// + public static class HotReloadAddedMemberExternHost + { + [DllImport("hot_reload_test_missing_library")] + public static extern int Missing(int value); + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int ExternHostConcrete() + { + return 1; + } + } + + /// + /// Compiled generic abstract host so a bodyless method on a generic type, which the worker + /// hard-skips before it looks at the body, can be classified when no baseline exists. + /// + public abstract class HotReloadAddedMemberGenericAbstractHost + { + public abstract int GenericShape(int value); + } } From bf15c25a2f49a0f5ec3bb925fbf266a7a6cdedea Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 13:03:00 +0900 Subject: [PATCH 2/7] fix: Say a bodyless compiled member was not compared when hot reload has no baseline Without a compiled-source baseline the worker targets every method in the file, and a compiled member without a body (a bodyless interface method, an abstract or extern method) came back Skipped with a reason that pointed at a compile: "Interface members are not patchable. Run 'uloop compile'." or the generic "Methods without a body" text. Editing only a body elsewhere in the file was enough to produce it. Such a member has nothing to patch. What the worker cannot tell is whether its default values, attributes or parameter names changed, so the row stays and now carries MethodTransformBodylessNotCompared, which says exactly that and asks for a compile only if one of those was edited. The reason replaces any skip reason for such a member, hard skips (generic, struct host, partial) included, since no reason about patching applies to a member with no body. Added methods keep their reasons (they need a compile regardless), as do explicit interface implementations, which are never matched against the compiled type and so cannot be called compiled. With a baseline nothing changes. --- .../HotReloadWorkerReasonTextTests.cs | 6 + .../TransformWorkerAddedMemberTests.cs | 187 ++++++++++++++++++ .../Shared/HotReloadWorkerReasonCode.cs | 1 + ...rkerReasonText.MethodTransformTemplates.cs | 7 + .../TransformWorker~/OrdinaryMethodQueue.cs | 42 +++- 5 files changed, 241 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs index f1a6fdac7b..9b2684fc94 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs @@ -190,6 +190,12 @@ private static IEnumerable RenderCases() HotReloadWorkerReasonCode.MethodTransformNoBody, NoArgs, "Methods without a body (abstract/extern) are skipped."); + yield return Case( + HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared, + NoArgs, + "Has no body, so there is nothing to patch. It could not be compared with the compiled source, " + + "so a change to its default values, attributes or parameter names cannot be detected; " + + "run 'uloop compile' only if you made one."); yield return Case( HotReloadWorkerReasonCode.MethodTransformBaseMemberCall, NoArgs, diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs index 94fab69642..a75da5d0fc 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs @@ -2115,6 +2115,150 @@ public async Task Drift_SkippedAddedMethod_DoesNotFireOutsideBodyWarning() + string.Join("\n", result.Output.files[0].declarationDriftWarnings ?? Array.Empty())); } + /// + /// What: without a baseline, a compiled interface method that has no body is reported as + /// not compared, while a compiled default interface method keeps the interface reason. + /// + [Test] + public async Task NoBaseline_CompiledBodylessInterfaceMethod_SaysItWasNotCompared() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineBodylessInterface.cs", onDisk), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(IHotReloadAddedMemberPing), "Ping", new[] { "System.Int32" })), + Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared)); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(IHotReloadAddedMemberDefault), "ExistingDefault", Array.Empty())), + Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodInterfaceMember)); + } + + /// + /// What: without a baseline, a bodyless method added to a compiled interface keeps the + /// interface reason because only a compile can add it. + /// + [Test] + public async Task NoBaseline_AddedBodylessInterfaceMethod_KeepsTheInterfaceReason() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " int Ping(int value);", + " int Ping(int value);\n int Extra(int value);", + StringComparison.Ordinal); + Assert.That(edited, Is.Not.EqualTo(onDisk)); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineAddedBodylessInterface.cs", edited), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(IHotReloadAddedMemberPing), "Extra", new[] { "System.Int32" })), + Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodInterfaceMember)); + } + + /// + /// What: without a baseline, a compiled abstract method is reported as not compared, and + /// the concrete method on the same type is still queued for patching. + /// + [Test] + public async Task NoBaseline_CompiledAbstractMethod_SaysItWasNotCompared() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineAbstract.cs", onDisk), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(HotReloadAddedMemberAbstractHost), "Shape", new[] { "System.Int32" })), + Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared)); + Assert.That( + FindEntryOnType(result, typeof(HotReloadAddedMemberAbstractHost), "AbstractHostConcrete"), + Is.Not.Null); + } + + /// + /// What: without a baseline, a compiled extern method is reported as not compared, and + /// the concrete method on the same type is still queued for patching. + /// + [Test] + public async Task NoBaseline_CompiledExternMethod_SaysItWasNotCompared() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineExtern.cs", onDisk), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(HotReloadAddedMemberExternHost), "Missing", new[] { "System.Int32" })), + Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared)); + Assert.That( + FindEntryOnType(result, typeof(HotReloadAddedMemberExternHost), "ExternHostConcrete"), + Is.Not.Null); + } + + /// + /// What: without a baseline, a compiled bodyless method on a generic type is reported as + /// not compared instead of the generic hard-skip reason. + /// + [Test] + public async Task NoBaseline_CompiledAbstractMethodOnGenericType_SaysItWasNotCompared() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineGenericAbstract.cs", onDisk), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel( + typeof(HotReloadAddedMemberGenericAbstractHost<>), + "GenericShape", + new[] { "System.Int32" })), + Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared)); + } + + /// + /// What: with a baseline, a compiled bodyless interface method whose default value changed + /// keeps the interface reason because the change was detected. + /// + [Test] + public async Task WithBaseline_ChangedBodylessInterfaceMethod_KeepsTheInterfaceReason() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " int Ping(int value);", + " int Ping(int value = 1);", + StringComparison.Ordinal); + Assert.That(edited, Is.Not.EqualTo(onDisk)); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("WithBaselineChangedBodylessInterface.cs", edited), + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(IHotReloadAddedMemberPing), "Ping", new[] { "System.Int32" })), + Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodInterfaceMember)); + } + /// /// What: baseline null does not report removed members even when the edited source drops /// a compiled method. @@ -2511,6 +2655,49 @@ private static void AssertHasSkip( + FormatSkipped(result.Output.skipped)); } + private static string FormatHostLabel(Type type, string methodName, string[] parameterTypeFullNames) + { + return HotReloadMethodKeys.FormatMethodLabelParts( + new HotReloadMetadataTypeName(type.FullName), + methodName, + parameterTypeFullNames, + genericArity: 0); + } + + private static HotReloadWorkerReasonCode? FindSkipReasonCode( + TransformWorkerClientResult result, + string exactLabel) + { + foreach (TransformWorkerSkippedDto skipped in result.Output.skipped) + { + if (skipped.method == exactLabel) + { + return skipped.reason.code; + } + } + + Assert.Fail( + "Expected skip with label '" + exactLabel + "'. Skipped=" + + FormatSkipped(result.Output.skipped)); + return null; + } + + private static TransformWorkerEntryDto FindEntryOnType( + TransformWorkerClientResult result, + Type type, + string methodName) + { + foreach (TransformWorkerEntryDto entry in result.Output.entries) + { + if (entry.typeMetadataName == type.FullName && entry.methodName == methodName) + { + return entry; + } + } + + return null; + } + private static string FindSkipMethod( TransformWorkerClientResult result, string methodNameFragment) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs index 99ad93bd00..ff5a756435 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs @@ -15,6 +15,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal enum HotReloadWorkerReasonCode { MethodTransformNoBody, + MethodTransformBodylessNotCompared, MethodTransformBaseMemberCall, MethodTransformClosureInaccessibleAccess, MethodTransformAsyncIteratorInaccessibleAccess, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs index 630ced6741..0174d8118e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs @@ -25,6 +25,13 @@ private static void AddMethodTransformTemplates( templates.Add( HotReloadWorkerReasonCode.MethodTransformNoBody, Plain("Methods without a body (abstract/extern) are skipped.", 0)); + templates.Add( + HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared, + Plain( + "Has no body, so there is nothing to patch. It could not be compared with the compiled source, " + + "so a change to its default values, attributes or parameter names cannot be detected; " + + "run 'uloop compile' only if you made one.", + 0)); templates.Add( HotReloadWorkerReasonCode.MethodTransformBaseMemberCall, Plain( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs index 450816aa6d..a58e900676 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs @@ -78,6 +78,7 @@ internal static void QueueOrdinaryMethod( replacesCompiledMethod, hasBaseline, syntaxMethodKey, + methodDeclaration, methodSymbol, typeState, snapshotMethodMap, @@ -121,11 +122,17 @@ internal static void QueueOrdinaryMethod( semanticModel); if (decision.SkipReason != null) { + // Why every skip reason is replaced, hard skips included: a member with no body has + // nothing to patch, so a reason that blames the host type or asks for a compile misleads. + WorkerReason skipReason = + IsBodylessCompiledMethodWithoutBaseline(isAddedMethod, hasBaseline, methodDeclaration) + ? WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared) + : decision.SkipReason; skipped.Add(new WorkerSkipped { SourceProjectRelativePath = typeState.SourceUnit.Input.ProjectRelativePath, Method = WorkerMethodKeys.FormatMethodLabel(methodSymbol), - Reason = decision.SkipReason + Reason = skipReason }); if (isAddedMethod) { @@ -219,6 +226,7 @@ internal static bool TrySkipInterfaceOrdinaryMethod( bool replacesCompiledMethod, bool hasBaseline, string syntaxMethodKey, + MethodDeclarationSyntax methodDeclaration, IMethodSymbol methodSymbol, TypeEmitState typeState, Dictionary snapshotMethodMap, @@ -245,7 +253,9 @@ internal static bool TrySkipInterfaceOrdinaryMethod( { SourceProjectRelativePath = typeState.SourceUnit.Input.ProjectRelativePath, Method = WorkerMethodKeys.FormatMethodLabel(methodSymbol), - Reason = WorkerReason.Of(HotReloadWorkerReasonCode.AddedMethodInterfaceMember) + Reason = IsBodylessCompiledMethodWithoutBaseline(isAddedMethod, hasBaseline, methodDeclaration) + ? WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared) + : WorkerReason.Of(HotReloadWorkerReasonCode.AddedMethodInterfaceMember) }); if (isAddedMethod) { @@ -260,6 +270,34 @@ internal static bool TrySkipInterfaceOrdinaryMethod( return true; } + /// + /// True when a compiled method without a body could not be compared with the compiled source: + /// there is nothing to patch, and an edit to its default values, attributes or parameter names + /// cannot be told apart from no edit. + /// + /// + /// With a baseline the change is known, so the existing reasons stay. An added method needs a + /// compile whatever its body. An explicit interface implementation is never matched against the + /// compiled type, so it cannot be called compiled. + /// + internal static bool IsBodylessCompiledMethodWithoutBaseline( + bool isAddedMethod, + bool hasBaseline, + MethodDeclarationSyntax methodDeclaration) + { + if (hasBaseline || isAddedMethod) + { + return false; + } + + if (methodDeclaration.ExplicitInterfaceSpecifier != null) + { + return false; + } + + return methodDeclaration.Body == null && methodDeclaration.ExpressionBody == null; + } + internal static MethodTransformDecision DecideOrdinaryMethodTransform( bool isAddedMethod, MethodDeclarationSyntax methodDeclaration, From 7894ea620bbb9487c2c3a829e83be78b8a67d4f4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 13:04:49 +0900 Subject: [PATCH 3/7] fix: Do not start a compile only for bodyless rows hot reload could not compare With the default --compile-on-skip auto, any Skipped row in a requested file makes the CLI compile in the same command. A file with no compiled-source baseline that declares a bodyless member (an interface method, an abstract or extern method) therefore always paid for a compile, even when only a body elsewhere was edited and every body patched. HasUnappliedEdit now leaves out Skipped rows whose worker reason is MethodTransformBodylessNotCompared: such a member has no body to apply. Any other Skipped row beside it still counts. The cost is that changing only the default values, attributes or parameter names of such a member no longer compiles on its own; the row's sentence asks for a compile in that case, and a signature change is classified as an added method, which still counts. --- .../HotReloadCompileFallbackDeciderTests.cs | 55 +++++++++++++++++++ .../HotReloadCompileFallbackDecider.cs | 13 ++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompileFallbackDeciderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompileFallbackDeciderTests.cs index 163ad28393..dbe2398fa7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompileFallbackDeciderTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompileFallbackDeciderTests.cs @@ -99,6 +99,55 @@ public void HasUnappliedEdit_SkippedMethod_IsTrue() Assert.That(HotReloadCompileFallbackDecider.HasUnappliedEdit(result, NoActivePatchSiblings()), Is.True); } + /// + /// What: skipped rows for bodyless members that could not be compared leave nothing to + /// patch, so they alone do not count as an unapplied edit. + /// + [Test] + public void HasUnappliedEdit_SkippedRowsThatOnlyHaveNoBodyToPatch_AreNotUnappliedEdits() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + SkippedWithWorkerReason( + "IA.Ping(System.Int32)", + HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared), + SkippedWithWorkerReason( + "B.Shape(System.Int32)", + HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared), + HotReloadMethodOutcome.Patched("A.Foo()", "Assets/A.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1); + + Assert.That(HotReloadCompileFallbackDecider.HasUnappliedEdit(result, NoActivePatchSiblings()), Is.False); + } + + /// + /// What: a skipped row with any other reason still counts as an unapplied edit when it sits + /// next to a bodyless row that could not be compared. + /// + [Test] + public void HasUnappliedEdit_ABodylessRowNextToAnotherSkippedRow_StillCounts() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + SkippedWithWorkerReason( + "IA.Ping(System.Int32)", + HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared), + SkippedWithWorkerReason( + "A.Foo()", + HotReloadWorkerReasonCode.MethodTransformBaseMemberCall) + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0); + + Assert.That(HotReloadCompileFallbackDecider.HasUnappliedEdit(result, NoActivePatchSiblings()), Is.True); + } + /// /// What: one failed method makes the run count as leaving an edit unapplied. /// @@ -323,6 +372,12 @@ public void ForActivePatches_ResultWithRetriedAndActivePatchSiblings_ContainsOnl Assert.That(siblings.Contains(retriedSiblingPath), Is.False); } + private static HotReloadMethodOutcome SkippedWithWorkerReason(string method, HotReloadWorkerReasonCode code) + { + return HotReloadMethodOutcome.Skipped(method, "reason", "Assets/A.cs") + .WithWorkerReason(HotReloadWorkerReasonFacts.From(new TransformWorkerReasonDto { code = code })); + } + private static HotReloadReappliedSiblingFiles NoActivePatchSiblings() { return new HotReloadReappliedSiblingFiles(Array.Empty(), path => path); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs index e6689fa52a..7d9edc990c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs @@ -31,6 +31,10 @@ internal static class HotReloadCompileFallbackDecider /// an edit that was never applied. /// A declaration a sibling owns is left out even when refused: an introduced type is never /// unloaded, so a failed run does not take the sibling's earlier declaration away. + /// A Skipped row for a compiled member without a body that could not be compared with the + /// compiled source is left out too: it has no body to apply. An edit to only its default + /// values, attributes or parameter names therefore does not start a compile on its own; + /// the row's sentence asks for one instead. /// internal static bool HasUnappliedEdit( HotReloadOrchestratorResult result, @@ -44,7 +48,8 @@ internal static bool HasUnappliedEdit( } if (method.Kind == HotReloadMethodOutcomeKind.Skipped - && !activePatchSiblingFiles.Contains(method.FilePath)) + && !activePatchSiblingFiles.Contains(method.FilePath) + && !IsBodylessRowThatWasNotCompared(method)) { return true; } @@ -66,6 +71,12 @@ internal static bool HasUnappliedEdit( return false; } + private static bool IsBodylessRowThatWasNotCompared(HotReloadMethodOutcome method) + { + return method.WorkerReason != null + && method.WorkerReason.Code == HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared; + } + /// /// Chooses the fallback for one run. isPlaying and compileRefusedDuringPlay (Unity's /// "Script Changes While Playing" set to recompile only after play ends) are parameters From b29f83957b5114b51ad67fccd310bac10753260e Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 13:16:51 +0900 Subject: [PATCH 4/7] docs: Describe the not-compared reason for bodyless members in the hot-reload skill The hot-reload skill said every Skipped row of a requested file makes --compile-on-skip compile, and that the single-compile line is left off whenever such a row exists. Rows for a compiled member without a body that could not be compared (no source baseline) are now left out of both, so the parameter table, the CompileFallback and Warnings entries, and the no-baseline paragraph of the scope reference say so, including what the row's reason asks the reader to do. The tool catalog and the generated skill copies are regenerated from the edited sources. --- .agents/skills/uloop-hot-reload/SKILL.md | 2 +- .agents/skills/uloop-hot-reload/references/output.md | 4 ++-- .../skills/uloop-hot-reload/references/scope-and-limits.md | 6 +++++- .claude/skills/uloop-hot-reload/SKILL.md | 2 +- .claude/skills/uloop-hot-reload/references/output.md | 4 ++-- .../skills/uloop-hot-reload/references/scope-and-limits.md | 6 +++++- .../src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md | 2 +- .../FirstPartyTools/HotReload/Skill/references/output.md | 4 ++-- .../HotReload/Skill/references/scope-and-limits.md | 6 +++++- cli/common/tools/default-tools.json | 2 +- 10 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/SKILL.md b/.agents/skills/uloop-hot-reload/SKILL.md index 78ea33a883..fb5bd0885f 100644 --- a/.agents/skills/uloop-hot-reload/SKILL.md +++ b/.agents/skills/uloop-hot-reload/SKILL.md @@ -40,7 +40,7 @@ automatically — pass it with `--files`. | `--files` | array | - | Project-relative `.cs` paths to hot-reload (method bodies, added members, and new top-level types). When omitted or empty on apply, selects compiled snapshot sources only — those whose bytes changed since the last compile snapshot, capped at 50 changed files per assembly with a warning when the cap trims the list; a file that has never been compiled is never selected and must be passed explicitly, except an introduced type's file after Play Mode entry or `--revert-all`, which is selected again; run `uloop compile` first when no snapshot exists, or pass explicit paths when no changed source is found | | `--revert-all` | flag | - | Remove every active hot-reload patch and added member and clear the ledger; introduced types stay loaded until the next domain reload. When set, `--files` is ignored | | `--status` | flag | - | Lists the currently active changes (patched methods, added members, and introduced types) without applying or reverting anything. | -| `--compile-on-skip` | enum | `auto` | When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows aside), run `uloop compile` in this command: `auto` only in Edit Mode (never stops a Play session), `on` always unless Unity holds compiles until Play ends, `off` never. The response's `CompileFallback` says which; when the compile ran, `Compile` carries its result and `Success` is the compile's. | +| `--compile-on-skip` | enum | `auto` | When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows and uncompared bodyless members aside), run `uloop compile` in this command: `auto` only in Edit Mode (never stops a Play session), `on` always unless Unity holds compiles until Play ends, `off` never. The response's `CompileFallback` says which; when the compile ran, `Compile` carries its result and `Success` is the compile's. | ## Status diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index 249f1eb1b8..b89255cf6b 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -19,7 +19,7 @@ Returns JSON with: - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. - `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` -- `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped` (other than a bodyless member that could not be compared for lack of a source baseline): that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. - `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run - `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. @@ -39,7 +39,7 @@ Returns JSON with: - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` - `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold. - `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. -- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. +- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. Nor does a `Skipped` row for a compiled member without a body that could not be compared because the file has no source baseline: it has no body to apply, and its `Reason` asks for a compile only if its default values, attributes, or parameter names changed. - `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`. - `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`. - `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 6314eb1af7..062de842d2 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -345,7 +345,11 @@ the baseline again is unpatched on that run — the compiled IL comes back, Without a baseline — for example before the first compile after installing or updating the package — every editable method in the file is patched and a `Warnings` line reports the fallback; run `uloop compile` -to establish the baseline. Files the reload only re-applied as siblings share one +to establish the baseline. A compiled member of that file without a body (an +interface, abstract, or extern method) is `Skipped` with a reason saying it could not +be compared: there is nothing to patch, but an edit to only its default values, +attributes, or parameter names goes undetected, so compile only if you made one. Such +a row does not make `--compile-on-skip` compile. Files the reload only re-applied as siblings share one such line per reason, `N re-applied sibling file(s) ...: `, instead of one line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that diff --git a/.claude/skills/uloop-hot-reload/SKILL.md b/.claude/skills/uloop-hot-reload/SKILL.md index 78ea33a883..fb5bd0885f 100644 --- a/.claude/skills/uloop-hot-reload/SKILL.md +++ b/.claude/skills/uloop-hot-reload/SKILL.md @@ -40,7 +40,7 @@ automatically — pass it with `--files`. | `--files` | array | - | Project-relative `.cs` paths to hot-reload (method bodies, added members, and new top-level types). When omitted or empty on apply, selects compiled snapshot sources only — those whose bytes changed since the last compile snapshot, capped at 50 changed files per assembly with a warning when the cap trims the list; a file that has never been compiled is never selected and must be passed explicitly, except an introduced type's file after Play Mode entry or `--revert-all`, which is selected again; run `uloop compile` first when no snapshot exists, or pass explicit paths when no changed source is found | | `--revert-all` | flag | - | Remove every active hot-reload patch and added member and clear the ledger; introduced types stay loaded until the next domain reload. When set, `--files` is ignored | | `--status` | flag | - | Lists the currently active changes (patched methods, added members, and introduced types) without applying or reverting anything. | -| `--compile-on-skip` | enum | `auto` | When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows aside), run `uloop compile` in this command: `auto` only in Edit Mode (never stops a Play session), `on` always unless Unity holds compiles until Play ends, `off` never. The response's `CompileFallback` says which; when the compile ran, `Compile` carries its result and `Success` is the compile's. | +| `--compile-on-skip` | enum | `auto` | When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows and uncompared bodyless members aside), run `uloop compile` in this command: `auto` only in Edit Mode (never stops a Play session), `on` always unless Unity holds compiles until Play ends, `off` never. The response's `CompileFallback` says which; when the compile ran, `Compile` carries its result and `Success` is the compile's. | ## Status diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index 249f1eb1b8..b89255cf6b 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -19,7 +19,7 @@ Returns JSON with: - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. - `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` -- `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped` (other than a bodyless member that could not be compared for lack of a source baseline): that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. - `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run - `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. @@ -39,7 +39,7 @@ Returns JSON with: - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` - `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold. - `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. -- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. +- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. Nor does a `Skipped` row for a compiled member without a body that could not be compared because the file has no source baseline: it has no body to apply, and its `Reason` asks for a compile only if its default values, attributes, or parameter names changed. - `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`. - `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`. - `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 6314eb1af7..062de842d2 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -345,7 +345,11 @@ the baseline again is unpatched on that run — the compiled IL comes back, Without a baseline — for example before the first compile after installing or updating the package — every editable method in the file is patched and a `Warnings` line reports the fallback; run `uloop compile` -to establish the baseline. Files the reload only re-applied as siblings share one +to establish the baseline. A compiled member of that file without a body (an +interface, abstract, or extern method) is `Skipped` with a reason saying it could not +be compared: there is nothing to patch, but an edit to only its default values, +attributes, or parameter names goes undetected, so compile only if you made one. Such +a row does not make `--compile-on-skip` compile. Files the reload only re-applied as siblings share one such line per reason, `N re-applied sibling file(s) ...: `, instead of one line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md index 78ea33a883..fb5bd0885f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md @@ -40,7 +40,7 @@ automatically — pass it with `--files`. | `--files` | array | - | Project-relative `.cs` paths to hot-reload (method bodies, added members, and new top-level types). When omitted or empty on apply, selects compiled snapshot sources only — those whose bytes changed since the last compile snapshot, capped at 50 changed files per assembly with a warning when the cap trims the list; a file that has never been compiled is never selected and must be passed explicitly, except an introduced type's file after Play Mode entry or `--revert-all`, which is selected again; run `uloop compile` first when no snapshot exists, or pass explicit paths when no changed source is found | | `--revert-all` | flag | - | Remove every active hot-reload patch and added member and clear the ledger; introduced types stay loaded until the next domain reload. When set, `--files` is ignored | | `--status` | flag | - | Lists the currently active changes (patched methods, added members, and introduced types) without applying or reverting anything. | -| `--compile-on-skip` | enum | `auto` | When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows aside), run `uloop compile` in this command: `auto` only in Edit Mode (never stops a Play session), `on` always unless Unity holds compiles until Play ends, `off` never. The response's `CompileFallback` says which; when the compile ran, `Compile` carries its result and `Success` is the compile's. | +| `--compile-on-skip` | enum | `auto` | When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows and uncompared bodyless members aside), run `uloop compile` in this command: `auto` only in Edit Mode (never stops a Play session), `on` always unless Unity holds compiles until Play ends, `off` never. The response's `CompileFallback` says which; when the compile ran, `Compile` carries its result and `Success` is the compile's. | ## Status diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index 249f1eb1b8..b89255cf6b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -19,7 +19,7 @@ Returns JSON with: - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. - `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` -- `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. +- `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped` (other than a bodyless member that could not be compared for lack of a source baseline): that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. - `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run - `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal` (number): `Methods[]` rows of this run whose `Kind` is `Skipped`, `Added`, `Failed`, `AlreadyActive`, or `Stale`, sibling rows included, as in `PatchedTotal`. `0` on `--status`, `--revert-all`, and validation failures. @@ -39,7 +39,7 @@ Returns JSON with: - `ActiveIntroducedTypeTotal` (number): Introduced types this domain holds after this run or on `--status`, counted per type rather than per compiled artifact; always present and `0` when there is none. `--revert-all` cannot unload them, so its Message says how many stay loaded until the next Domain Reload, and that Auto Refresh stays held for them until `uloop compile` - `Message` (string): Short summary. When a run carries `IntroducedTypes` rows, Message reports them: a run that only introduced or only re-bound types says so instead of reporting the methods, a refused declaration is reported as the failure of the run and points at `IntroducedTypes`, and a run the methods decided ends with `IntroducedTypes=N`. On apply runs that pulled in sibling files, Message follows `PatchedTotal` and `Added` with how many of those Patched and Added rows re-applied the siblings' earlier changes (left out when 0). On apply runs, Message counts the patched rows that carry a `LifecycleNote` in one sentence and the added Unity messages a hot-reload proxy delivers in another, both pointing at `Methods[].LifecycleNote`; forwarded `Added` rows are not in the patched count, and each sentence is left out when its count is 0. On `--status`, Message opens with how many changes are currently active — patched methods, added members, and introduced types together, which is why it can exceed `ActivePatchTotal` — and when any `Active` or `Added` row has `InvocationCount` 0 it also appends how many such rows there are and points at `Methods[].Reason`. `--revert-all` appends how many introduced types stay loaded until the next Domain Reload, and when the hold is still armed for them, that Auto Refresh stays held until `uloop compile`. When the fallback compile succeeds, the CLI sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, and removes the hold sentence (`AutoRefreshHoldMessage`) from `Message`, because the compile released the hold. - `RecommendedNextAction` (string): Present in three cases. (1) Any method or introduced-type outcome is `Failed`. A failure that needs a change in the source keeps the fix advice: a partial apply (some methods patched or added, or some types left active) says to fix and rerun, run `uloop compile`, or `uloop hot-reload --revert-all`; a failure with nothing applied says to fix and rerun or compile. When every failure came from the Editor compiling or importing while the reload ran, it says there is nothing to fix: wait until `uloop status` no longer reports that, then rerun, or run `uloop compile` (after a partial apply, `uloop hot-reload --revert-all` is offered as well); beside failures that need a fix, the fix advice comes first and adds that those rows need only a rerun. A missing compiled assembly adds that there is nothing to fix in the source and that `uloop compile` is the step; in a Multiplayer Play Mode Virtual Player, that compile has to run against the main Editor's project, because a patch applied in the main Editor does not reach the player. (2) Every method of the requested files was `Skipped`, which still answers `Success`: it points first at the fix each Skipped row's `Methods[].Reason` names and offers `uloop compile` as the alternative. (3) `CompileFallback` is `HeldForPlayMode` or `BlockedByPlayModeSetting`, whatever the outcomes: the reason no compile ran is appended after any advice from (1) or (2), and it opens by saying to do any fix a `Reason` names that needs no compile before compiling. Omitted otherwise. -- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. +- `CompileFallback` (string, always present): whether the CLI should run a compile after this run — `NotNeeded`, `Requested`, `HeldForPlayMode` (edits stayed unapplied but the Editor is in Play Mode and `--compile-on-skip` is `auto`), `BlockedByPlayModeSetting` (`--compile-on-skip on` during Play Mode while Unity's "Script Changes While Playing" is "Recompile After Finished Playing", which refuses the compile; `RecommendedNextAction` says to stop Play Mode first), or `Disabled` (`--compile-on-skip off`). `--status`, `--revert-all` and validation failures answer `NotNeeded`. `Skipped` rows of a sibling pulled in to re-bind its active patches do not count as unapplied edits: they are not this run's edits, and their earlier patches stay active. Its `Failed` rows do count, because a failed reload reverts those patches. A sibling retried after an earlier Skip, or brought in as a companion, still counts. Nor does a `Skipped` row for a compiled member without a body that could not be compared because the file has no source baseline: it has no body to apply, and its `Reason` asks for a compile only if its default values, attributes, or parameter names changed. - `RetryAfterEditorReady` (bool, apply runs only): `true` when every failure of the run is the Editor compiling or importing, whether the request arrived during the compile or the compile started before the reload could be applied. The CLI then waits for the Editor to settle (up to 10 minutes) and applies the same files again in the same command, so the response you read is the second apply's. `false` on every other failure, and on `--status` and `--revert-all`. - `SelectedFiles` (string[], apply runs only): the project-relative asset paths of the scripts the run selected, whether given as `--files` or chosen as the changed files. The retry sends exactly these, as an explicit list, so a file the default selection would leave out is not left out on the second apply. Empty on `--status` and `--revert-all`. - `Compile` (object, present only when the CLI ran the fallback compile): the full `uloop compile` response; the top-level `Success` is then the compile's, and the command's exit code is the compile's. A successful compile drops `RecommendedNextAction`, sets `Outcome` to `ReplacedByCompile` and `AutoRefreshHeld` to `false`, removes the hold sentence from `Message`, and ends `Message` with a sentence saying the compile succeeded; a failed one leaves `Outcome`, `AutoRefreshHeld`, and `Message` as the reload reported them, and sets `RecommendedNextAction` to the compile's own `NextActions` when it reports any, and otherwise to fixing `Compile.Errors`. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 6314eb1af7..062de842d2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -345,7 +345,11 @@ the baseline again is unpatched on that run — the compiled IL comes back, Without a baseline — for example before the first compile after installing or updating the package — every editable method in the file is patched and a `Warnings` line reports the fallback; run `uloop compile` -to establish the baseline. Files the reload only re-applied as siblings share one +to establish the baseline. A compiled member of that file without a body (an +interface, abstract, or extern method) is `Skipped` with a reason saying it could not +be compared: there is nothing to patch, but an edit to only its default values, +attributes, or parameter names goes undetected, so compile only if you made one. Such +a row does not make `--compile-on-skip` compile. Files the reload only re-applied as siblings share one such line per reason, `N re-applied sibling file(s) ...: `, instead of one line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 2612aa8262..05360489a3 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -785,7 +785,7 @@ "on", "off" ], - "description": "When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows aside), run uloop compile in this command: auto only in Edit Mode (never stops a Play session), on always unless Unity holds compiles until Play ends, off never. The response's CompileFallback says which; when the compile ran, Compile carries its result and Success is the compile's.", + "description": "When edits stay unapplied (Skipped/Failed methods, Failed types; a re-bound sibling's Skipped rows and uncompared bodyless members aside), run uloop compile in this command: auto only in Edit Mode (never stops a Play session), on always unless Unity holds compiles until Play ends, off never. The response's CompileFallback says which; when the compile ran, Compile carries its result and Success is the compile's.", "default": "auto" } } From 8268bc10a95337feba658002df8df2359b375da1 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 14:07:31 +0900 Subject: [PATCH 5/7] fix: Keep the compile reasons when an edit removes a compiled method's body Without a baseline, the not-compared reason only looked at the edited source. The compiled-method match compares name, arity, static-ness and signature but not abstract or extern, so an edit that removed a body (virtual to abstract, a method made extern, a default interface method made bodyless) got the new reason and no automatic compile, while the compiled body kept running until a compile. The reason now also requires the matched compiled method to have no body (abstract or extern). The parameter match is split out of MatchCompiledOrdinaryMethod as FindCompiledOrdinaryMethod so both use the same lookup, and the decision is made once per method. --- .../TransformWorkerAddedMemberTests.cs | 76 +++++++++++++++++++ .../TransformWorker~/CompiledMemberMatcher.cs | 32 +++++--- .../TransformWorker~/OrdinaryMethodQueue.cs | 38 ++++++++-- 3 files changed, 128 insertions(+), 18 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs index a75da5d0fc..4bace6c67f 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs @@ -2234,6 +2234,82 @@ public async Task NoBaseline_CompiledAbstractMethodOnGenericType_SaysItWasNotCom Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared)); } + /// + /// What: without a baseline, a compiled method with a body that the edit made abstract keeps + /// the no-body reason, because the compiled body keeps running until a compile. + /// + [Test] + public async Task NoBaseline_CompiledMethodMadeAbstract_KeepsTheNoBodyReason() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " public int AbstractHostConcrete()\n {\n return 1;\n }", + " public abstract int AbstractHostConcrete();", + StringComparison.Ordinal); + Assert.That(edited, Is.Not.EqualTo(onDisk)); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineMadeAbstract.cs", edited), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(HotReloadAddedMemberAbstractHost), "AbstractHostConcrete", Array.Empty())), + Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformNoBody)); + } + + /// + /// What: without a baseline, a compiled method with a body that the edit made extern keeps + /// the no-body reason, because the compiled body keeps running until a compile. + /// + [Test] + public async Task NoBaseline_CompiledMethodMadeExtern_KeepsTheNoBodyReason() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " public static int ExternHostConcrete()\n {\n return 1;\n }", + " [DllImport(\"hot_reload_test_missing_library\")]\n" + + " public static extern int ExternHostConcrete();", + StringComparison.Ordinal); + Assert.That(edited, Is.Not.EqualTo(onDisk)); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineMadeExtern.cs", edited), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(HotReloadAddedMemberExternHost), "ExternHostConcrete", Array.Empty())), + Is.EqualTo(HotReloadWorkerReasonCode.MethodTransformNoBody)); + } + + /// + /// What: without a baseline, a compiled default interface method that the edit made bodyless + /// keeps the interface reason, because the compiled default body keeps running until a compile. + /// + [Test] + public async Task NoBaseline_DefaultInterfaceMethodMadeBodyless_KeepsTheInterfaceReason() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = onDisk.Replace( + " int ExistingDefault() => 1;", + " int ExistingDefault();", + StringComparison.Ordinal); + Assert.That(edited, Is.Not.EqualTo(onDisk)); + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + WriteEdited("NoBaselineDefaultMadeBodyless.cs", edited), + HostProjectRelativePath, + snapshotSource: null); + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That( + FindSkipReasonCode( + result, + FormatHostLabel(typeof(IHotReloadAddedMemberDefault), "ExistingDefault", Array.Empty())), + Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodInterfaceMember)); + } + /// /// What: with a baseline, a compiled bodyless interface method whose default value changed /// keeps the interface reason because the change was detected. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledMemberMatcher.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledMemberMatcher.cs index b0acaa0b97..cb845fe4dc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledMemberMatcher.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/CompiledMemberMatcher.cs @@ -21,6 +21,25 @@ internal static class CompiledMemberMatcher internal static CompiledMethodMatch MatchCompiledOrdinaryMethod( INamedTypeSymbol compiledType, IMethodSymbol sourceMethod) + { + IMethodSymbol compiledMethod = FindCompiledOrdinaryMethod(compiledType, sourceMethod); + if (compiledMethod == null) + { + return CompiledMethodMatch.NotFound; + } + + return ReturnTypesMatch(compiledMethod, sourceMethod) + ? CompiledMethodMatch.Matched + : CompiledMethodMatch.ReturnTypeChanged; + } + + /// + /// The compiled ordinary method with the source method's name, arity, static-ness and parameter + /// types, whatever its return type; null when there is none. + /// + internal static IMethodSymbol FindCompiledOrdinaryMethod( + INamedTypeSymbol compiledType, + IMethodSymbol sourceMethod) { string[] sourceParameterTypeFullNames = sourceMethod.Parameters .Select(CecilTypeNames.ToParameterTypeFullName) @@ -48,20 +67,13 @@ internal static CompiledMethodMatch MatchCompiledOrdinaryMethod( } } - if (!parametersMatch) - { - continue; - } - - if (ReturnTypesMatch(compiledMethod, sourceMethod)) + if (parametersMatch) { - return CompiledMethodMatch.Matched; + return compiledMethod; } - - return CompiledMethodMatch.ReturnTypeChanged; } - return CompiledMethodMatch.NotFound; + return null; } internal static bool ReturnTypesMatch(IMethodSymbol compiledMethod, IMethodSymbol sourceMethod) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs index a58e900676..a9bd983ef9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs @@ -56,6 +56,12 @@ internal static void QueueOrdinaryMethod( methodDeclaration, compiledType, methodSymbol); + bool bodylessNotCompared = IsBodylessCompiledMethodWithoutBaseline( + isAddedMethod, + hasBaseline, + methodDeclaration, + compiledType, + methodSymbol); if (TrySkipExcludedOrdinaryMethod( isAddedMethod, replacesCompiledMethod, @@ -78,7 +84,7 @@ internal static void QueueOrdinaryMethod( replacesCompiledMethod, hasBaseline, syntaxMethodKey, - methodDeclaration, + bodylessNotCompared, methodSymbol, typeState, snapshotMethodMap, @@ -124,8 +130,7 @@ internal static void QueueOrdinaryMethod( { // Why every skip reason is replaced, hard skips included: a member with no body has // nothing to patch, so a reason that blames the host type or asks for a compile misleads. - WorkerReason skipReason = - IsBodylessCompiledMethodWithoutBaseline(isAddedMethod, hasBaseline, methodDeclaration) + WorkerReason skipReason = bodylessNotCompared ? WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared) : decision.SkipReason; skipped.Add(new WorkerSkipped @@ -226,7 +231,7 @@ internal static bool TrySkipInterfaceOrdinaryMethod( bool replacesCompiledMethod, bool hasBaseline, string syntaxMethodKey, - MethodDeclarationSyntax methodDeclaration, + bool bodylessNotCompared, IMethodSymbol methodSymbol, TypeEmitState typeState, Dictionary snapshotMethodMap, @@ -253,7 +258,7 @@ internal static bool TrySkipInterfaceOrdinaryMethod( { SourceProjectRelativePath = typeState.SourceUnit.Input.ProjectRelativePath, Method = WorkerMethodKeys.FormatMethodLabel(methodSymbol), - Reason = IsBodylessCompiledMethodWithoutBaseline(isAddedMethod, hasBaseline, methodDeclaration) + Reason = bodylessNotCompared ? WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared) : WorkerReason.Of(HotReloadWorkerReasonCode.AddedMethodInterfaceMember) }); @@ -278,12 +283,17 @@ internal static bool TrySkipInterfaceOrdinaryMethod( /// /// With a baseline the change is known, so the existing reasons stay. An added method needs a /// compile whatever its body. An explicit interface implementation is never matched against the - /// compiled type, so it cannot be called compiled. + /// compiled type, so it cannot be called compiled. The compiled method must lack a body as well: + /// the signature match ignores abstract and extern, so an edit that removes a body (virtual to + /// abstract, a method made extern, a default interface method made bodyless) leaves the + /// compiled body running until a compile. /// internal static bool IsBodylessCompiledMethodWithoutBaseline( bool isAddedMethod, bool hasBaseline, - MethodDeclarationSyntax methodDeclaration) + MethodDeclarationSyntax methodDeclaration, + INamedTypeSymbol compiledType, + IMethodSymbol methodSymbol) { if (hasBaseline || isAddedMethod) { @@ -295,7 +305,19 @@ internal static bool IsBodylessCompiledMethodWithoutBaseline( return false; } - return methodDeclaration.Body == null && methodDeclaration.ExpressionBody == null; + if (methodDeclaration.Body != null || methodDeclaration.ExpressionBody != null) + { + return false; + } + + IMethodSymbol compiledMethod = CompiledMemberMatcher.FindCompiledOrdinaryMethod(compiledType, methodSymbol); + if (compiledMethod == null) + { + throw new InvalidOperationException( + "A method classified as compiled has no compiled counterpart: " + methodSymbol.ToDisplayString()); + } + + return compiledMethod.IsAbstract || compiledMethod.IsExtern; } internal static MethodTransformDecision DecideOrdinaryMethodTransform( From c347f4163a5b3670e3b3535eef69bd7e91585b02 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 14:16:01 +0900 Subject: [PATCH 6/7] fix: Do not report bodyless rows hot reload could not compare as skipped edits When every requested-file row was a bodyless member that could not be compared (an interface-only file, for example), the compile fallback said NotNeeded but the rest of the response still asked for a compile: Outcome NothingApplied, the all-skipped Message ending "Run 'uloop compile' to apply these edits.", and the all-skipped RecommendedNextAction. An agent reading it compiled anyway. Whether such a row is an unapplied edit is now one property, HotReloadMethodOutcome.IsBodylessNotCompared, used by the compile fallback, the Outcome's Skipped check, and the all-skipped check. A run with only such rows reports NothingToApply without asking for a compile; a run that patched methods beside them reports Applied. Any other Skipped row of the requested files still counts as before. The rows themselves, SkippedTotal, the missing-baseline warning, and the Warnings echo stay. --- .../Editor/HotReload/HotReloadToolTests.cs | 115 ++++++++++++++++++ .../HotReload/HotReloadApplyOutcome.cs | 9 +- .../HotReloadCompileFallbackDecider.cs | 8 +- .../HotReload/HotReloadMethodOutcome.cs | 12 ++ .../HotReloadRequestedFileOutcomeSummary.cs | 7 ++ 5 files changed, 141 insertions(+), 10 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index 83eef59828..843af82479 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -2803,6 +2803,112 @@ public void BuildApplyResponse_RequestedFileAllSkippedWithoutSiblings_OmitsTheSi Is.EqualTo(HotReloadConstants.RequestedFilesAllSkippedRecommendedNextAction)); } + /// + /// What: a run whose requested-file rows are all bodyless members that could not be + /// compared has nothing to apply, so neither its outcome, its message, its next action nor + /// the compile fallback asks for a compile. + /// + [Test] + public void BuildApplyResponse_RequestedFileOnlyHasBodylessNotComparedRows_ReportsNothingToApply() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + SkippedBodylessNotCompared("IA.Ping(System.Int32)", "Assets/Requested.cs"), + SkippedBodylessNotCompared("IA.Pong()", "Assets/Requested.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0); + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + HotReloadTool.ApplyCompileFallbackDecision( + response, + result, + HotReloadCompileOnSkip.auto, + isPlaying: false, + compileRefusedDuringPlay: false, + activePatchSiblingFiles: NoActivePatchSiblings()); + + Assert.That(response.Success, Is.True); + Assert.That(response.Outcome, Is.EqualTo("NothingToApply")); + Assert.That(response.SkippedTotal, Is.EqualTo(2)); + Assert.That(response.Message, Does.Not.Contain("Run 'uloop compile' to apply")); + Assert.That(response.RecommendedNextAction, Is.Empty); + Assert.That(response.CompileFallback, Is.EqualTo("NotNeeded")); + } + + /// + /// What: a run that patched a method beside a bodyless member that could not be compared + /// reports Applied, because the bodyless row is not a skipped edit. + /// + [Test] + public void BuildApplyResponse_PatchedRowBesideABodylessNotComparedRow_ReportsApplied() + { + HotReloadResponse response = HotReloadTool.BuildApplyResponse( + new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("T.M()", "Assets/Requested.cs"), + SkippedBodylessNotCompared("T.Shape(System.Int32)", "Assets/Requested.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1)); + + Assert.That(response.Outcome, Is.EqualTo("Applied")); + Assert.That(response.RecommendedNextAction, Is.Empty); + } + + /// + /// What: a bodyless row that could not be compared does not hide another Skipped row of + /// the requested file: the run still reports NothingApplied and recommends the fix or a + /// compile. + /// + [Test] + public void BuildApplyResponse_BodylessNotComparedRowBesideAnotherSkippedRow_StillReportsNothingApplied() + { + HotReloadResponse response = HotReloadTool.BuildApplyResponse( + new HotReloadOrchestratorResult( + new List + { + SkippedBodylessNotCompared("T.Shape(System.Int32)", "Assets/Requested.cs"), + HotReloadMethodOutcome.Skipped("T.M()", "reason", "Assets/Requested.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0)); + + Assert.That(response.Outcome, Is.EqualTo("NothingApplied")); + Assert.That( + response.Message, + Does.StartWith(HotReloadConstants.RequestedFilesAllSkippedMessage)); + Assert.That( + response.RecommendedNextAction, + Is.EqualTo(HotReloadConstants.RequestedFilesAllSkippedRecommendedNextAction)); + } + + /// + /// What: a patched row, a bodyless row that could not be compared and another Skipped row + /// report PartiallyApplied. + /// + [Test] + public void BuildApplyResponse_PatchedBodylessAndSkippedRows_ReportsPartiallyApplied() + { + HotReloadResponse response = HotReloadTool.BuildApplyResponse( + new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("T.N()", "Assets/Requested.cs"), + SkippedBodylessNotCompared("T.Shape(System.Int32)", "Assets/Requested.cs"), + HotReloadMethodOutcome.Skipped("T.M()", "reason", "Assets/Requested.cs") + }, + new List(), + patchedTotal: 1, + activePatchTotal: 1)); + + Assert.That(response.Outcome, Is.EqualTo("PartiallyApplied")); + } + /// /// What: a run that bound an introduced type keeps the type message and no next action, /// even though the only method outcome of the requested file was Skipped. @@ -3189,6 +3295,15 @@ public async Task ExecuteAsync_ValidationFailure_WritesNotNeededCompileFallback( } // One skipped method: the smallest run that leaves a requested edit unapplied. + private static HotReloadMethodOutcome SkippedBodylessNotCompared(string method, string filePath) + { + return HotReloadMethodOutcome.Skipped(method, "reason", filePath) + .WithWorkerReason(HotReloadWorkerReasonFacts.From(new TransformWorkerReasonDto + { + code = HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared + })); + } + private static HotReloadReappliedSiblingFiles NoActivePatchSiblings() { return new HotReloadReappliedSiblingFiles(Array.Empty(), path => path); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs index 174bbeb3cf..d23333a577 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyOutcome.cs @@ -21,7 +21,8 @@ internal enum HotReloadApplyOutcomeKind NothingApplied = 2, // Nothing of the requested files is live, and none was Skipped: every method is - // unchanged, or the files hold no method bodies. + // unchanged, or the files hold no method bodies. A Skipped row for a bodyless member that + // could not be compared does not count as Skipped here: it has no body to apply. NothingToApply = 3, // A Failed row, siblings included: the same condition that turns Success false. @@ -38,7 +39,8 @@ internal enum HotReloadApplyOutcomeKind /// Why the sibling rows are left out: a sibling file is re-applied on the run's own initiative, /// so its rows say nothing about the edit the caller asked about (the same split the message /// uses through HotReloadRequestedFileOutcomeSummary). A Failed row counts wherever it is, - /// because Success already turns false for it. + /// because Success already turns false for it. A Skipped row for a bodyless member that could + /// not be compared is not a skipped edit, so a run whose other rows all applied is Applied. /// internal static class HotReloadApplyOutcome { @@ -129,7 +131,8 @@ private static bool HasRequestedSkippedMethod( { HotReloadMethodOutcome method = methods[index]; if (method.Kind == HotReloadMethodOutcomeKind.Skipped - && !siblingFiles.Contains(method.FilePath)) + && !siblingFiles.Contains(method.FilePath) + && !method.IsBodylessNotCompared) { return true; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs index 7d9edc990c..699286ce8a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompileFallbackDecider.cs @@ -49,7 +49,7 @@ internal static bool HasUnappliedEdit( if (method.Kind == HotReloadMethodOutcomeKind.Skipped && !activePatchSiblingFiles.Contains(method.FilePath) - && !IsBodylessRowThatWasNotCompared(method)) + && !method.IsBodylessNotCompared) { return true; } @@ -71,12 +71,6 @@ internal static bool HasUnappliedEdit( return false; } - private static bool IsBodylessRowThatWasNotCompared(HotReloadMethodOutcome method) - { - return method.WorkerReason != null - && method.WorkerReason.Code == HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared; - } - /// /// Chooses the fallback for one run. isPlaying and compileRefusedDuringPlay (Unity's /// "Script Changes While Playing" set to recompile only after play ends) are parameters diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs index dda10102cd..8b519f16f8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadMethodOutcome.cs @@ -24,6 +24,18 @@ internal sealed class HotReloadMethodOutcome // other row. public HotReloadFailureKinds FailureKinds { get; } + /// + /// True for a Skipped row of a compiled member without a body that could not be compared + /// with the compiled source. It has no body to apply, so it is not an unapplied edit: the + /// outcome, the all-skipped answer and the compile fallback all leave it out, and its + /// Reason asks for a compile only if the member's default values, attributes or parameter + /// names changed. + /// + public bool IsBodylessNotCompared => + Kind == HotReloadMethodOutcomeKind.Skipped + && WorkerReason != null + && WorkerReason.Code == HotReloadWorkerReasonCode.MethodTransformBodylessNotCompared; + private HotReloadMethodOutcome( HotReloadMethodOutcomeKind kind, string method, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs index 71a5ce5420..78cb5fd195 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs @@ -43,6 +43,13 @@ public static bool AreAllRequestedOutcomesSkipped( continue; } + // Why left out: such a row has no body to apply, so a run whose other rows all + // applied, or that has no other rows, did not leave an asked-for edit unapplied. + if (outcome.IsBodylessNotCompared) + { + continue; + } + requestedCount++; if (outcome.Kind != HotReloadMethodOutcomeKind.Skipped) { From 475c481a657cb83ebb9c2d05cc2142af85ca457c Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 14:16:27 +0900 Subject: [PATCH 7/7] docs: Say bodyless rows hot reload could not compare leave Outcome and the all-skipped advice alone The output reference now lists those rows under NothingToApply and says a run that patched the rest of the file is Applied. The no-baseline paragraph of the scope reference says the compiled member must lack a body too, and that an edit removing a compiled body keeps the ordinary Skipped reasons. --- .../skills/uloop-hot-reload/references/output.md | 4 +++- .../references/scope-and-limits.md | 14 +++++++++----- .../skills/uloop-hot-reload/references/output.md | 4 +++- .../references/scope-and-limits.md | 14 +++++++++----- .../HotReload/Skill/references/output.md | 4 +++- .../HotReload/Skill/references/scope-and-limits.md | 14 +++++++++----- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index b89255cf6b..eb3d3b8ca5 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -5,7 +5,9 @@ - `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. - `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. - `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. -- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale` or `Skipped` rows for bodyless members that could not be compared. + +A `Skipped` row for a compiled member without a body that could not be compared for lack of a source baseline is not counted as a skipped edit by `Outcome`: it has nothing to apply. A run that patched the rest of the file is `Applied`; its `Reason` asks for a compile only if the member's default values, attributes, or parameter names changed. - `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. - `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 062de842d2..5cbad6a194 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -345,11 +345,15 @@ the baseline again is unpatched on that run — the compiled IL comes back, Without a baseline — for example before the first compile after installing or updating the package — every editable method in the file is patched and a `Warnings` line reports the fallback; run `uloop compile` -to establish the baseline. A compiled member of that file without a body (an -interface, abstract, or extern method) is `Skipped` with a reason saying it could not -be compared: there is nothing to patch, but an edit to only its default values, -attributes, or parameter names goes undetected, so compile only if you made one. Such -a row does not make `--compile-on-skip` compile. Files the reload only re-applied as siblings share one +to establish the baseline. A member of that file without a body (an interface, +abstract, or extern method) that has no body in the compiled assembly either is +`Skipped` with a reason saying it could not be compared: there is nothing to patch, but +an edit to only its default values, attributes, or parameter names goes undetected, so +compile only if you made one. Such a row does not make `--compile-on-skip` compile, does +not count as a skipped edit in `Outcome` (a file holding nothing else is +`NothingToApply`), and does not bring the all-skipped advice to compile. An edit that +removes a compiled body keeps the ordinary `Skipped` reasons, because the compiled +body runs until a compile. Files the reload only re-applied as siblings share one such line per reason, `N re-applied sibling file(s) ...: `, instead of one line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index b89255cf6b..eb3d3b8ca5 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -5,7 +5,9 @@ - `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. - `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. - `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. -- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale` or `Skipped` rows for bodyless members that could not be compared. + +A `Skipped` row for a compiled member without a body that could not be compared for lack of a source baseline is not counted as a skipped edit by `Outcome`: it has nothing to apply. A run that patched the rest of the file is `Applied`; its `Reason` asks for a compile only if the member's default values, attributes, or parameter names changed. - `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. - `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 062de842d2..5cbad6a194 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -345,11 +345,15 @@ the baseline again is unpatched on that run — the compiled IL comes back, Without a baseline — for example before the first compile after installing or updating the package — every editable method in the file is patched and a `Warnings` line reports the fallback; run `uloop compile` -to establish the baseline. A compiled member of that file without a body (an -interface, abstract, or extern method) is `Skipped` with a reason saying it could not -be compared: there is nothing to patch, but an edit to only its default values, -attributes, or parameter names goes undetected, so compile only if you made one. Such -a row does not make `--compile-on-skip` compile. Files the reload only re-applied as siblings share one +to establish the baseline. A member of that file without a body (an interface, +abstract, or extern method) that has no body in the compiled assembly either is +`Skipped` with a reason saying it could not be compared: there is nothing to patch, but +an edit to only its default values, attributes, or parameter names goes undetected, so +compile only if you made one. Such a row does not make `--compile-on-skip` compile, does +not count as a skipped edit in `Outcome` (a file holding nothing else is +`NothingToApply`), and does not bring the all-skipped advice to compile. An edit that +removes a compiled body keeps the ordinary `Skipped` reasons, because the compiled +body runs until a compile. Files the reload only re-applied as siblings share one such line per reason, `N re-applied sibling file(s) ...: `, instead of one line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index b89255cf6b..eb3d3b8ca5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -5,7 +5,9 @@ - `Applied` — the edits of the files you asked about are live (`Patched`, `Added`, or `AlreadyActive` rows, or introduced types), and none was `Skipped`. Nothing else to do. - `PartiallyApplied` — some of them are live, some were `Skipped`; read the `Methods[]` rows with `Kind: "Skipped"`. - `NothingApplied` — none of them is live, and some were `Skipped`. `Warnings` (or `Methods[].Reason`) says why. -- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale`. +- `NothingToApply` — nothing changed against the compiled code (see `UnchangedTotal`), the files hold no method bodies, or their only rows are `Stale` or `Skipped` rows for bodyless members that could not be compared. + +A `Skipped` row for a compiled member without a body that could not be compared for lack of a source baseline is not counted as a skipped edit by `Outcome`: it has nothing to apply. A run that patched the rest of the file is `Applied`; its `Reason` asks for a compile only if the member's default values, attributes, or parameter names changed. - `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. - `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 062de842d2..5cbad6a194 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -345,11 +345,15 @@ the baseline again is unpatched on that run — the compiled IL comes back, Without a baseline — for example before the first compile after installing or updating the package — every editable method in the file is patched and a `Warnings` line reports the fallback; run `uloop compile` -to establish the baseline. A compiled member of that file without a body (an -interface, abstract, or extern method) is `Skipped` with a reason saying it could not -be compared: there is nothing to patch, but an edit to only its default values, -attributes, or parameter names goes undetected, so compile only if you made one. Such -a row does not make `--compile-on-skip` compile. Files the reload only re-applied as siblings share one +to establish the baseline. A member of that file without a body (an interface, +abstract, or extern method) that has no body in the compiled assembly either is +`Skipped` with a reason saying it could not be compared: there is nothing to patch, but +an edit to only its default values, attributes, or parameter names goes undetected, so +compile only if you made one. Such a row does not make `--compile-on-skip` compile, does +not count as a skipped edit in `Outcome` (a file holding nothing else is +`NothingToApply`), and does not bring the all-skipped advice to compile. An edit that +removes a compiled body keeps the ordinary `Skipped` reasons, because the compiled +body runs until a compile. Files the reload only re-applied as siblings share one such line per reason, `N re-applied sibling file(s) ...: `, instead of one line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that