From 8a5902ac961de87a7a910b10724790f3972f5346 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 21:23:44 +0900 Subject: [PATCH 01/11] Add failing worker tests for internal members used next to a partial type A run that holds a partial type skipped method bodies that use an internal member of a type the run was not given, and a sibling brought back to re-bind its patches lost the same kind of call. These Repro_ tests pin the expected outcome (the body binds) for each shape seen in the field, with a control run from a plain type in the same test, plus the member kinds the investigation needs to cover. They fail on purpose until the cause is fixed. --- .../HotReloadInternalMemberCaller.cs | 23 ++ .../HotReloadInternalMemberCaller.cs.meta | 11 + .../HotReload/HotReloadInternalMemberHost.cs | 60 ++++ .../HotReloadInternalMemberHost.cs.meta | 11 + .../HotReloadPartialDerivedFixture.cs | 17 ++ .../HotReloadPartialDerivedFixture.cs.meta | 11 + ...tReloadPartialInternalGuardedPeer.Extra.cs | 19 ++ ...adPartialInternalGuardedPeer.Extra.cs.meta | 11 + .../HotReloadPartialInternalPeer.Extra.cs | 17 ++ ...HotReloadPartialInternalPeer.Extra.cs.meta | 11 + .../HotReload/HotReloadPartialInternalPeer.cs | 30 ++ .../HotReloadPartialInternalPeer.cs.meta | 11 + .../HotReload/HotReloadPlainDerivedFixture.cs | 17 ++ .../HotReloadPlainDerivedFixture.cs.meta | 11 + .../TransformWorkerPartialTypeTests.cs | 278 ++++++++++++++++++ 15 files changed, 538 insertions(+) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs new file mode 100644 index 0000000000..a1152f7a1c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs @@ -0,0 +1,23 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A plain type that calls an internal member of another plain type. The visibility repro tests + /// pass it on its own, next to an edit of a partial type, or as a sibling a run brought back. + /// + public class HotReloadInternalMemberCaller + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int CallsInternal() + { + return HotReloadInternalMemberHost.InternalStaticValue(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int PlainValue() + { + return 1; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs.meta new file mode 100644 index 0000000000..795aa51f20 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb228d70ed39d446e87083c77cbfb0a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs new file mode 100644 index 0000000000..8b7e5fb3df --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A plain type whose non-public members the visibility repro tests call from edited bodies. + /// No test passes this file to a run, so the worker can only see the type as compiled. + /// + public class HotReloadInternalMemberHost + { + internal int InternalField = 3; + + internal int InternalProperty + { + get { return 4; } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static int InternalStaticValue() + { + return 1; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal int InternalInstanceValue() + { + return 2; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + protected int ProtectedValue() + { + return 5; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + protected internal int ProtectedInternalValue() + { + return 6; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private protected int PrivateProtectedValue() + { + return 7; + } + } + + /// + /// An internal type the visibility repro tests name from edited bodies. + /// + internal static class HotReloadInternalOnlyType + { + [MethodImpl(MethodImplOptions.NoInlining)] + public static int Value() + { + return 8; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs.meta new file mode 100644 index 0000000000..d1617955de --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d10bb384a8c545c5b44131477f562be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs new file mode 100644 index 0000000000..2ca6d2f8cc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A partial type deriving from , so its edited bodies can + /// reach the protected members of a compiled base type. + /// + public partial class HotReloadPartialDerivedFixture : HotReloadInternalMemberHost + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int DerivedValue() + { + return 9; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs.meta new file mode 100644 index 0000000000..07b4403f42 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4173d2e8c5b8c4f6592fccd1ad5a5eb8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs new file mode 100644 index 0000000000..4e2bf02d05 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs @@ -0,0 +1,19 @@ +#if UNITY_EDITOR +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The other part of . The whole file sits in a + /// conditional-compilation block, the way an editor-only or debug-only part usually does. + /// + public partial class HotReloadPartialInternalGuardedPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + internal int GuardedPeerInternalValue() + { + return 12; + } + } +} +#endif diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs.meta new file mode 100644 index 0000000000..45ed9d9773 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalGuardedPeer.Extra.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c534c4bfd695c4efa9de7002d82bb910 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs new file mode 100644 index 0000000000..20011441fe --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The other part of , holding the internal method the + /// visibility repro tests call. + /// + public partial class HotReloadPartialInternalPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + internal int PeerInternalValue() + { + return 11; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs.meta new file mode 100644 index 0000000000..989f477d61 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.Extra.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9bd97bfefce64410a8e7eb97a622374 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs new file mode 100644 index 0000000000..25953d41dc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs @@ -0,0 +1,30 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// The main part of a partial type whose internal method lives in another file. No test passes + /// this file, so the worker sees the type as compiled. + /// + public partial class HotReloadPartialInternalPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int PeerMain() + { + return 1; + } + } + + /// + /// The main part of a partial type whose internal method lives in a file wrapped in a + /// conditional-compilation block. + /// + public partial class HotReloadPartialInternalGuardedPeer + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int GuardedPeerMain() + { + return 2; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs.meta new file mode 100644 index 0000000000..14240d0979 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialInternalPeer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a28b8e4d2be89448dbe32de6cef97290 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs new file mode 100644 index 0000000000..c24ed8b7b3 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A plain type deriving from : the control for the + /// partial type that derives from it. + /// + public class HotReloadPlainDerivedFixture : HotReloadInternalMemberHost + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int DerivedValue() + { + return 10; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs.meta new file mode 100644 index 0000000000..522553368f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52992140133b44b419f2e5f5e401d603 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs index 0b32ff3d25..54b7d14bcf 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs @@ -67,6 +67,18 @@ public class TransformWorkerPartialTypeTests private const string OtherPartPropertyGetter = "get { return OtherPartProperty; }"; private const string OtherPartPropertyGetterEdited = "get { return OtherPartProperty + 100; }"; + // Fixtures of the internal-visibility repro tests. None of them declares the types whose + // non-public members the edited bodies use, so the worker sees those types as compiled. + private const string FixtureDirectoryProjectRelativePath = "Assets/Tests/Editor/HotReload/"; + private const string CallerFileName = "HotReloadInternalMemberCaller.cs"; + private const string PlainDerivedFileName = "HotReloadPlainDerivedFixture.cs"; + private const string PartialDerivedFileName = "HotReloadPartialDerivedFixture.cs"; + private const string CallerInternalCallBody = "return HotReloadInternalMemberHost.InternalStaticValue();"; + private const string CallerInternalCallBodyEdited = "return HotReloadInternalMemberHost.InternalStaticValue() + 100;"; + private const string CallerPlainValueBody = "return 1;"; + private const string PlainDerivedValueBody = "return 10;"; + private const string PartialDerivedValueBody = "return 9;"; + /// /// What: a body that reads a private field declared in another part of the type is emitted. /// @@ -503,6 +515,272 @@ public async Task Skip_PartialTypeBodyEdit_WhenAnotherRunFileCannotBeRead_NamesT AssertFileHasParseErrors(result, UnreadableProjectRelativePath); } + /// + /// What: shape A of the internal-visibility regression. A body of a partial type that calls an + /// internal method of a plain type the run was not given binds, as the same call from a plain + /// type does (the control run of the same test). + /// + [Test] + public async Task Repro_PartialTypeBodyCallingInternalMethodOfUnpassedPlainType_Binds() + { + const string call = "HotReloadInternalMemberHost.InternalStaticValue()"; + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproAControl.cs", call) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproAPartial.cs", call) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: shape B of the regression with the plain file passed. A plain type's body that calls + /// an internal method of an unpassed type binds when an edit of a partial type is in the same + /// run, as it does when the plain file is passed alone (the control run of the same test). + /// + [Test] + public async Task Repro_PlainFilePassedNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerInternalCallEdit("ReproB1Control.cs") + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproB1Partial.cs", "2"), + BuildCallerInternalCallEdit("ReproB1Caller.cs") + }); + + AssertControlAndReproEmitted(control, "CallsInternal", repro, "CallsInternal"); + } + + /// + /// What: shape B of the regression as the field run had it: the plain file comes back as a + /// sibling to re-bind its active patches while an edit of a partial type is passed. Its body + /// that calls an internal method of an unpassed type binds, as it does when the passed edit is + /// in a plain type (the control run of the same test). + /// + [Test] + public async Task Repro_PlainSiblingBroughtBackNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource(PlainDerivedFileName, "ReproB2ControlPassed.cs", PlainDerivedValueBody, "return 11;"), + AsReappliedSibling(BuildCallerInternalCallEdit("ReproB2ControlSibling.cs")) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproB2Partial.cs", "2"), + AsReappliedSibling(BuildCallerInternalCallEdit("ReproB2Sibling.cs")) + }); + + AssertControlAndReproEmitted(control, "CallsInternal", repro, "CallsInternal"); + } + + /// + /// What: shape C of the regression. A body of a partial type that calls an internal method + /// another partial type declares in a separate file the run was not given binds, as the same + /// call from a plain type does (the control run of the same test). + /// + [Test] + public async Task Repro_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialType_Binds() + { + const string call = "new HotReloadPartialInternalPeer().PeerInternalValue()"; + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproC1Control.cs", call) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproC1Partial.cs", call) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: shape C with the other partial type's file wrapped whole in a conditional-compilation + /// block whose symbol the assembly defines: the call binds, as the same call from a plain type + /// does (the control run of the same test). + /// + [Test] + public async Task Repro_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialTypeInAConditionalFile_Binds() + { + const string call = "new HotReloadPartialInternalGuardedPeer().GuardedPeerInternalValue()"; + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproC2Control.cs", call) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproC2Partial.cs", call) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: the reach of the regression over member kinds. A body of a partial type that uses a + /// non-public member of a type the run was not given binds, as the same use from a plain type + /// does (the control run of the same test). + /// + [TestCase("InternalInstanceMethod", "new HotReloadInternalMemberHost().InternalInstanceValue()")] + [TestCase("InternalField", "new HotReloadInternalMemberHost().InternalField")] + [TestCase("InternalProperty", "new HotReloadInternalMemberHost().InternalProperty")] + [TestCase("InternalType", "HotReloadInternalOnlyType.Value()")] + [TestCase("ProtectedInternalMethod", "new HotReloadInternalMemberHost().ProtectedInternalValue()")] + [TestCase( + "PublicMemberOfInternalTypeOfAnotherAssemblyThroughInternalsVisibleTo", + "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.HotReloadConstants.TestSourcesRelativeDirectory.Length")] + [TestCase( + "InternalMemberOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo", + "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointCapturedVariable.FromSnapshot(null).Name.Length")] + public async Task Repro_PartialTypeBodyUsingNonPublicMemberOfUnpassedType_Binds(string memberKind, string expression) + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildCallerPlainValueEdit("ReproKindControl" + memberKind + ".cs", expression) + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("ReproKindPartial" + memberKind + ".cs", expression) + }); + + AssertControlAndReproEmitted(control, "PlainValue", repro, "OwnOnly"); + } + + /// + /// What: the reach of the regression over members a compiled base type grants its derived + /// types. A body of a partial type that uses such a member binds, as the same use from a plain + /// derived type does (the control run of the same test). + /// + [TestCase("Protected", "ProtectedValue()")] + [TestCase("PrivateProtected", "PrivateProtectedValue()")] + [TestCase("InheritedInternal", "InternalInstanceValue()")] + public async Task Repro_DerivedPartialTypeBodyUsingBaseMemberOfUnpassedType_Binds(string memberKind, string expression) + { + TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PlainDerivedFileName, + "ReproBaseControl" + memberKind + ".cs", + PlainDerivedValueBody, + "return " + expression + ";") + }); + TransformWorkerClientResult repro = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PartialDerivedFileName, + "ReproBasePartial" + memberKind + ".cs", + PartialDerivedValueBody, + "return " + expression + ";") + }); + + AssertControlAndReproEmitted(control, "DerivedValue", repro, "DerivedValue"); + } + + // A fixture file next to these tests, edited once and described as a passed run source whose + // snapshot is the file on disk. + private static TransformWorkerSourceDto BuildEditedFixtureSource( + string fileName, + string editedFileName, + string fragment, + string replacement) + { + string onDisk = File.ReadAllText(ResolveFixturePath(fileName)); + return new TransformWorkerSourceDto + { + sourcePath = WriteEdited(editedFileName, ReplaceOnce(onDisk, fragment, replacement)), + projectRelativePath = FixtureDirectoryProjectRelativePath + fileName, + snapshotSource = onDisk + }; + } + + // The edited main part of the partial fixture, with OwnOnly returning the given expression. + private static TransformWorkerSourceDto BuildFixtureOwnOnlyEdit(string editedFileName, string returnedExpression) + { + string editedOwnOnly = + " public int OwnOnly()\n {\n return " + returnedExpression + ";\n }"; + return BuildEditedFixtureSource(FixtureFileName, editedFileName, OwnOnlyDeclaration, editedOwnOnly); + } + + // The plain caller with PlainValue returning the given expression. + private static TransformWorkerSourceDto BuildCallerPlainValueEdit(string editedFileName, string returnedExpression) + { + return BuildEditedFixtureSource( + CallerFileName, + editedFileName, + CallerPlainValueBody, + "return " + returnedExpression + ";"); + } + + // The plain caller with an edit inside the body that calls the internal method. + private static TransformWorkerSourceDto BuildCallerInternalCallEdit(string editedFileName) + { + return BuildEditedFixtureSource( + CallerFileName, + editedFileName, + CallerInternalCallBody, + CallerInternalCallBodyEdited); + } + + private static TransformWorkerSourceDto AsReappliedSibling(TransformWorkerSourceDto source) + { + source.reappliedSibling = true; + return source; + } + + // Why one assertion over both runs: the control and the repro fail for different reasons, and + // the reader needs both runs' rows even when the control already failed. + private static void AssertControlAndReproEmitted( + TransformWorkerClientResult control, + string controlMethodName, + TransformWorkerClientResult repro, + string reproMethodName) + { + List failures = new List(); + CollectMissingEntry("control", control, controlMethodName, failures); + CollectMissingEntry("repro", repro, reproMethodName, failures); + Assert.That(failures, Is.Empty, string.Join("\n\n", failures)); + } + + private static void CollectMissingEntry( + string runLabel, + TransformWorkerClientResult result, + string methodName, + List failures) + { + if (!result.Success) + { + failures.Add(runLabel + " run failed: " + result.ErrorMessage); + return; + } + + if (FindEntry(result, methodName) == null) + { + failures.Add( + runLabel + ": missing entry for " + methodName + ".\n" + + FormatSkipped(result) + "\n" + FormatFileErrors(result)); + } + } + + private static string FormatFileErrors(TransformWorkerClientResult result) + { + List rows = new List(); + foreach (TransformWorkerFileOutputDto file in result.Output.files) + { + foreach (string parseError in file.parseErrors ?? Array.Empty()) + { + rows.Add(file.projectRelativePath + " :: " + parseError); + } + } + + return rows.Count == 0 ? "FileErrors=(none)" : "FileErrors=\n" + string.Join("\n", rows); + } + // The edited main part. OwnOnly reads only its own part, so it binds whether or not the other // part is visible, and only an untrusted other part can keep it from being emitted. private static TransformWorkerSourceDto BuildOwnOnlyEditSource(string editedFileName) From 06f0895286297f99e4d75243ae5a41cd6fb42802 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 22:12:04 +0900 Subject: [PATCH 02/11] Add end-to-end tests for bodies using internal members of an unpassed type The worker cannot see internal members of compiled types, so a fix that lets the partial-type and sibling guards pass such bodies is only safe if the shim compile and the patched method still work. These tests reload each shape and call the patched method. - Run_: straight-line bodies of a plain type are patched and return the edited value; siblings and added methods keep a correct value today - Repro_ (failing): the same straight-line bodies on a partial type are skipped; on a plain type, a body using the member inside a lambda or iterator is reported Patched and then throws MethodAccessException, and an inherited internal method called by simple name fails the file --- .../HotReload/HotReloadInternalMemberHost.cs | 2 + .../HotReloadPartialDerivedFixture.cs | 24 + .../HotReload/HotReloadPlainDerivedFixture.cs | 24 + ...HotReloadUnpassedInternalMemberE2ETests.cs | 467 ++++++++++++++++++ ...loadUnpassedInternalMemberE2ETests.cs.meta | 11 + 5 files changed, 528 insertions(+) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs index 8b7e5fb3df..bd119f14ab 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs @@ -15,6 +15,8 @@ internal int InternalProperty get { return 4; } } + internal int InternalSettableProperty { get; set; } = 20; + [MethodImpl(MethodImplOptions.NoInlining)] internal static int InternalStaticValue() { diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs index 2ca6d2f8cc..c712126f8e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload @@ -8,10 +10,32 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// public partial class HotReloadPartialDerivedFixture : HotReloadInternalMemberHost { + private int _seed = 1000; + [MethodImpl(MethodImplOptions.NoInlining)] public int DerivedValue() { return 9; } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureValue() + { + Func read = () => 30; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedValue() + { + Func read = () => _seed; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IEnumerable IteratorValues() + { + yield return _seed; + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs index c24ed8b7b3..67aa388ef4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload @@ -8,10 +10,32 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// public class HotReloadPlainDerivedFixture : HotReloadInternalMemberHost { + private int _seed = 1000; + [MethodImpl(MethodImplOptions.NoInlining)] public int DerivedValue() { return 10; } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureValue() + { + Func read = () => 30; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedValue() + { + Func read = () => _seed; + return read(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IEnumerable IteratorValues() + { + yield return _seed; + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs new file mode 100644 index 0000000000..8e0b4190d6 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// End-to-end EditMode coverage for edited bodies that use internal members of a compiled type + /// the reload was not given: what each row reports, and what the method does when called. + /// + /// + /// Why two groups of edits: a straight-line body runs as IL copied into the patched method, + /// while a lambda or iterator body runs in code the shim assembly compiles on its own, and a + /// name inherited by simple name has to be qualified before the shim can compile it at all. + /// + public class HotReloadUnpassedInternalMemberE2ETests + { + private const string PlainDerivedFileName = "HotReloadPlainDerivedFixture.cs"; + private const string PartialDerivedFileName = "HotReloadPartialDerivedFixture.cs"; + private const string CallerFileName = "HotReloadInternalMemberCaller.cs"; + private const string PlainDerivedValueBody = "return 10;"; + private const string PartialDerivedValueBody = "return 9;"; + private const string DerivedValueAnchor = + " [MethodImpl(MethodImplOptions.NoInlining)]\n public int DerivedValue()"; + private const string DerivedValue = "DerivedValue"; + private const string ClosureValue = "ClosureValue"; + private const string ClosureSeedValue = "ClosureSeedValue"; + private const string IteratorValues = "IteratorValues"; + + // Public only because a test case argument has to be as visible as the test method. + public enum FixtureKind + { + Plain, + Partial + } + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + // Edits whose body runs as IL copied into the patched method. + private static IEnumerable EditsThatRunInThePatchedMethod() + { + yield return "InternalStaticMethod"; + yield return "InternalInstanceMethod"; + yield return "InternalFieldReadAndWrite"; + yield return "InternalPropertyGet"; + yield return "InternalPropertyGetAndSet"; + yield return "InheritedInternalMethodThroughThis"; + yield return "InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo"; + } + + // Edits the shim cannot run as written: a simple name it cannot qualify, or a use inside a + // lambda or iterator, which the shim assembly compiles as ordinary code of its own. + private static IEnumerable EditsThatDoNotRunInThePatchedMethod() + { + yield return "InheritedInternalMethodBySimpleName"; + yield return "InternalStaticMethodInLambda"; + yield return "InternalInstanceMethodInLambda"; + yield return "InternalStaticMethodInLambdaReadingOwnPrivateField"; + yield return "InternalStaticMethodInIteratorReadingOwnPrivateField"; + yield return "InternalInstanceMethodInIteratorReadingOwnPrivateField"; + yield return "InternalStaticMethodInIterator"; + } + + /// + /// What: an edited body of a plain type that uses an internal member of a compiled type the + /// reload was not given is patched, and the method returns the edited value. + /// + [TestCaseSource(nameof(EditsThatRunInThePatchedMethod))] + public async Task Run_PlainTypeBodyUsingInternalMemberOfUnpassedType_PatchesBehavior(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Plain, edit); + + AssertPatchedAsEdited(result, FixtureKind.Plain, edit); + } + + /// + /// What: the same edits on a partial type are patched, and the method returns the edited + /// value, as they are on a plain type. + /// + [TestCaseSource(nameof(EditsThatRunInThePatchedMethod))] + public async Task Repro_PartialTypeBodyUsingInternalMemberOfUnpassedType_PatchesBehavior(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Partial, edit); + + AssertPatchedAsEdited(result, FixtureKind.Partial, edit); + } + + /// + /// What: an edited body of a plain type that the shim cannot run as written is either + /// patched and returns the edited value, or skipped and keeps the compiled behavior. It is + /// never reported as patched and then fails when called, and never fails the file. + /// + [TestCaseSource(nameof(EditsThatDoNotRunInThePatchedMethod))] + public async Task Repro_PlainTypeBodyUsingInternalMemberOfUnpassedType_IsAppliedAsEditedOrSkipped(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Plain, edit); + + AssertAppliedAsEditedOrSkipped(result, FixtureKind.Plain, edit); + } + + /// + /// What: the same edits on a partial type are either patched and return the edited value, + /// or skipped and keep the compiled behavior. + /// + [TestCaseSource(nameof(EditsThatDoNotRunInThePatchedMethod))] + public async Task Run_PartialTypeBodyUsingInternalMemberOfUnpassedType_IsAppliedAsEditedOrSkipped(string editName) + { + BodyEdit edit = FindEdit(editName); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Partial, edit); + + AssertAppliedAsEditedOrSkipped(result, FixtureKind.Partial, edit); + } + + /// + /// What: a file whose applied body uses an internal member of a type the reload was not given + /// is brought back by a later reload of another file, and its method still returns the value + /// that body computes, whether the row re-applies it or skips it. + /// + [TestCase("CallsInternal", FixtureKind.Plain)] + [TestCase("CallsInternal", FixtureKind.Partial)] + [TestCase("PlainValue", FixtureKind.Plain)] + [TestCase("PlainValue", FixtureKind.Partial)] + public async Task Run_SiblingBroughtBackUsingInternalMemberOfUnpassedType_KeepsTheAppliedBodyRunning( + string methodName, + FixtureKind passedFixture) + { + bool callsInternal = methodName == "CallsInternal"; + int edited = callsInternal ? 201 : 202; + string callerPath = FixturePath(CallerFileName); + Dictionary overrides = new Dictionary + { + [callerPath] = WriteEdited( + callerPath, + "UnpassedInternalSiblingCaller.cs", + callsInternal ? "return HotReloadInternalMemberHost.InternalStaticValue();" : "return 1;", + callsInternal + ? "return HotReloadInternalMemberHost.InternalStaticValue() + 200;" + : "return new HotReloadInternalMemberHost().InternalInstanceValue() + 200;") + }; + HotReloadOrchestratorResult first = await RunAsync(new[] { callerPath }, overrides); + Assert.That(FindRow(first, "HotReloadInternalMemberCaller", methodName).Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Patched), "Precondition: the caller must be patched.\n" + FormatOutcomes(first)); + Assert.That(CallCaller(methodName), Is.EqualTo(edited), "Precondition: the caller must run the edited body.\n" + FormatOutcomes(first)); + + string passedPath = FixturePath(FixtureFileName(passedFixture)); + overrides[passedPath] = WriteEdited( + passedPath, + "UnpassedInternalSiblingPassed.cs", + ValueBody(passedFixture), + "return 50;"); + HotReloadOrchestratorResult second = await RunAsync(new[] { passedPath }, overrides); + + Assert.That(second.ReappliedSiblingPaths, Has.Some.EndsWith(CallerFileName), FormatOutcomes(second)); + Assert.That( + FindRow(second, "HotReloadInternalMemberCaller", methodName).Kind, + Is.EqualTo(HotReloadMethodOutcomeKind.Patched).Or.EqualTo(HotReloadMethodOutcomeKind.Skipped), + FormatOutcomes(second)); + Assert.That(CallCaller(methodName), Is.EqualTo(edited), FormatOutcomes(second)); + } + + /// + /// What: an added method whose body calls an internal method of a type the reload was not + /// given is either added and runs the edited body, or skipped together with its caller so + /// the compiled behavior stays. It is never added and then fails when called. + /// + [TestCase("HotReloadInternalMemberHost.InternalStaticValue() + 300", 301)] + [TestCase("new HotReloadInternalMemberHost().InternalInstanceValue() + 300", 302)] + public async Task Run_AddedMethodUsingInternalMemberOfUnpassedType_IsAddedAsEditedOrSkipped( + string expression, + int edited) + { + string path = FixturePath(PlainDerivedFileName); + string source = ReplaceOnce(File.ReadAllText(path), PlainDerivedValueBody, "return AddedInternalCall();"); + source = ReplaceOnce( + source, + DerivedValueAnchor, + " private int AddedInternalCall()\n {\n return " + expression + ";\n }\n\n" + + DerivedValueAnchor); + HotReloadOrchestratorResult result = await RunAsync( + new[] { path }, + new Dictionary + { + [path] = HotReloadTestSourceWriter.WriteEditedSource("UnpassedInternalAddedMethod.cs", source) + }); + + HotReloadMethodOutcomeKind addedKind = FindRow(result, "HotReloadPlainDerivedFixture", "AddedInternalCall").Kind; + Assert.That( + addedKind, + Is.EqualTo(HotReloadMethodOutcomeKind.Added).Or.EqualTo(HotReloadMethodOutcomeKind.Skipped), + FormatOutcomes(result)); + int expected = addedKind == HotReloadMethodOutcomeKind.Added ? edited : 10; + Assert.That(new HotReloadPlainDerivedFixture().DerivedValue(), Is.EqualTo(expected), FormatOutcomes(result)); + } + + private static BodyEdit FindEdit(string editName) + { + switch (editName) + { + case "InternalStaticMethod": + return BodyEdit.OfDerivedValue("return HotReloadInternalMemberHost.InternalStaticValue() + 100;", 101); + case "InternalInstanceMethod": + return BodyEdit.OfDerivedValue("return new HotReloadInternalMemberHost().InternalInstanceValue() + 100;", 102); + case "InternalFieldReadAndWrite": + return BodyEdit.OfDerivedValue( + "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost();\n" + + " host.InternalField = host.InternalField + 40;\n" + + " return host.InternalField + 100;", + 143); + case "InternalPropertyGet": + return BodyEdit.OfDerivedValue("return new HotReloadInternalMemberHost().InternalProperty + 100;", 104); + case "InternalPropertyGetAndSet": + return BodyEdit.OfDerivedValue( + "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost();\n" + + " host.InternalSettableProperty = host.InternalSettableProperty + 50;\n" + + " return host.InternalSettableProperty + 100;", + 170); + case "InheritedInternalMethodThroughThis": + return BodyEdit.OfDerivedValue("return this.InternalInstanceValue() + 100;", 102); + case "InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo": + return BodyEdit.OfDerivedValue( + "return global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointResponse" + + ".NormalizeNotCapturableVariables(new string[] { \"a\", \"b\" }).Count + 100;", + 102); + case "InheritedInternalMethodBySimpleName": + return BodyEdit.OfDerivedValue("return InternalInstanceValue() + 100;", 102); + case "InternalStaticMethodInLambda": + return new BodyEdit(ClosureValue, "() => 30", "() => HotReloadInternalMemberHost.InternalStaticValue() + 100", 101); + case "InternalInstanceMethodInLambda": + return new BodyEdit(ClosureValue, "() => 30", "() => new HotReloadInternalMemberHost().InternalInstanceValue() + 100", 102); + case "InternalStaticMethodInLambdaReadingOwnPrivateField": + return new BodyEdit(ClosureSeedValue, "() => _seed", "() => _seed + HotReloadInternalMemberHost.InternalStaticValue()", 1001); + case "InternalStaticMethodInIteratorReadingOwnPrivateField": + return new BodyEdit(IteratorValues, "yield return _seed;", "yield return _seed + HotReloadInternalMemberHost.InternalStaticValue();", 1001); + case "InternalInstanceMethodInIteratorReadingOwnPrivateField": + return new BodyEdit(IteratorValues, "yield return _seed;", "yield return _seed + new HotReloadInternalMemberHost().InternalInstanceValue();", 1002); + case "InternalStaticMethodInIterator": + return new BodyEdit(IteratorValues, "yield return _seed;", "yield return HotReloadInternalMemberHost.InternalStaticValue() + 100;", 101); + default: + throw new ArgumentException("Unknown edit: " + editName); + } + } + + private static async Task RunEditAsync(FixtureKind fixture, BodyEdit edit) + { + string path = FixturePath(FixtureFileName(fixture)); + string fragment = edit.Fragment ?? ValueBody(fixture); + return await RunAsync( + new[] { path }, + new Dictionary + { + [path] = WriteEdited(path, "UnpassedInternal" + fixture + edit.MethodName + ".cs", fragment, edit.Replacement) + }); + } + + private static void AssertPatchedAsEdited(HotReloadOrchestratorResult result, FixtureKind fixture, BodyEdit edit) + { + Assert.That(FindRow(result, FixtureTypeName(fixture), edit.MethodName).Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Patched), FormatOutcomes(result)); + Assert.That(CallFixture(fixture, edit.MethodName), Is.EqualTo(edit.EditedValue), FormatOutcomes(result)); + } + + // Why the value follows the row: a skipped row keeps the compiled body, and a patched row + // must run the edited one. A call that throws fails the test, which is the point. + private static void AssertAppliedAsEditedOrSkipped(HotReloadOrchestratorResult result, FixtureKind fixture, BodyEdit edit) + { + HotReloadMethodOutcomeKind kind = FindRow(result, FixtureTypeName(fixture), edit.MethodName).Kind; + Assert.That( + kind, + Is.EqualTo(HotReloadMethodOutcomeKind.Patched).Or.EqualTo(HotReloadMethodOutcomeKind.Skipped), + FormatOutcomes(result)); + int expected = kind == HotReloadMethodOutcomeKind.Patched ? edit.EditedValue : CompiledValue(fixture, edit.MethodName); + Assert.That(CallFixture(fixture, edit.MethodName), Is.EqualTo(expected), FormatOutcomes(result)); + } + + private static int CompiledValue(FixtureKind fixture, string methodName) + { + switch (methodName) + { + case DerivedValue: + return fixture == FixtureKind.Plain ? 10 : 9; + case ClosureValue: + return 30; + default: + return 1000; + } + } + + private static int CallFixture(FixtureKind fixture, string methodName) + { + if (fixture == FixtureKind.Plain) + { + HotReloadPlainDerivedFixture plain = new HotReloadPlainDerivedFixture(); + switch (methodName) + { + case DerivedValue: + return plain.DerivedValue(); + case ClosureValue: + return plain.ClosureValue(); + case ClosureSeedValue: + return plain.ClosureSeedValue(); + case IteratorValues: + return First(plain.IteratorValues()); + } + } + else + { + HotReloadPartialDerivedFixture partial = new HotReloadPartialDerivedFixture(); + switch (methodName) + { + case DerivedValue: + return partial.DerivedValue(); + case ClosureValue: + return partial.ClosureValue(); + case ClosureSeedValue: + return partial.ClosureSeedValue(); + case IteratorValues: + return First(partial.IteratorValues()); + } + } + + throw new ArgumentException("Unknown fixture method: " + methodName); + } + + private static int CallCaller(string methodName) + { + HotReloadInternalMemberCaller caller = new HotReloadInternalMemberCaller(); + return methodName == "CallsInternal" ? caller.CallsInternal() : caller.PlainValue(); + } + + private static int First(IEnumerable values) + { + foreach (int value in values) + { + return value; + } + + throw new InvalidOperationException("The iterator yielded nothing."); + } + + private static string FixtureFileName(FixtureKind fixture) + { + return fixture == FixtureKind.Plain ? PlainDerivedFileName : PartialDerivedFileName; + } + + private static string FixtureTypeName(FixtureKind fixture) + { + return fixture == FixtureKind.Plain ? nameof(HotReloadPlainDerivedFixture) : nameof(HotReloadPartialDerivedFixture); + } + + private static string ValueBody(FixtureKind fixture) + { + return fixture == FixtureKind.Plain ? PlainDerivedValueBody : PartialDerivedValueBody; + } + + // Why the type and the parenthesis: a bare method name would also match a longer name that + // contains it, or the same method on the other fixture type. + private static HotReloadMethodOutcome FindRow(HotReloadOrchestratorResult result, string typeName, string methodName) + { + string labelPart = "." + typeName + "." + methodName + "("; + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Method != null && outcome.Method.Contains(labelPart)) + { + return outcome; + } + } + + Assert.Fail("No row for " + typeName + "." + methodName + ".\n" + FormatOutcomes(result)); + return null; + } + + private static Task RunAsync(string[] files, Dictionary overrides) + { + return HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + files, + contentPathOverride: null, + CancellationToken.None, + new Dictionary(overrides)); + } + + private static string WriteEdited(string sourcePath, string editedFileName, string fragment, string replacement) + { + return HotReloadTestSourceWriter.WriteEditedSource( + editedFileName, + ReplaceOnce(File.ReadAllText(sourcePath), fragment, replacement)); + } + + // Why the uniqueness check: a fragment that also matched another member would edit a method + // the test does not call, and the assert would pass or fail for the wrong reason. + private static string ReplaceOnce(string source, string fragment, string replacement) + { + int first = source.IndexOf(fragment, StringComparison.Ordinal); + Assert.That(first, Is.GreaterThanOrEqualTo(0), "Fragment missing from the fixture: " + fragment); + Assert.That( + source.IndexOf(fragment, first + fragment.Length, StringComparison.Ordinal), + Is.EqualTo(-1), + "Fragment occurs more than once in the fixture: " + fragment); + return source.Replace(fragment, replacement, StringComparison.Ordinal); + } + + private static string FixturePath(string fileName) + { + string path = Path.GetFullPath(Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", fileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + + private static string FormatOutcomes(HotReloadOrchestratorResult result) + { + List lines = new List(); + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + lines.Add(outcome.Kind + " " + outcome.Method + " :: " + outcome.Reason); + } + + return string.Join("\n", lines) + "\nWarnings:\n" + string.Join("\n", result.Warnings); + } + + // One edit of a fixture method: the fragment it replaces (null for the fixture's own + // DerivedValue body, which differs between the two fixtures) and the value the edit returns. + private sealed class BodyEdit + { + internal BodyEdit(string methodName, string fragment, string replacement, int editedValue) + { + MethodName = methodName; + Fragment = fragment; + Replacement = replacement; + EditedValue = editedValue; + } + + internal string MethodName { get; } + internal string Fragment { get; } + internal string Replacement { get; } + internal int EditedValue { get; } + + internal static BodyEdit OfDerivedValue(string replacement, int editedValue) + { + return new BodyEdit(DerivedValue, null, replacement, editedValue); + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs.meta new file mode 100644 index 0000000000..ffe4cbfe7a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcb10cd9148204b379eb58e797e6a7fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 55e79c3ca66fd7fba9f225f93d4d1a5da3400211 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 23:42:18 +0900 Subject: [PATCH 03/11] Scope the internal-member tests to the partial-type fix The plain-type edits that run outside the patched method are a gap that predates partial-type support and stays out of this change, so their test goes. The partial-type patch test now covers only members of the edited file's own assembly; another assembly's internal member stays skipped from a partial type and gets a test of its own. A bare inherited name and the other-assembly member move to the skip tests. The remaining tests are renamed from Repro_ to Run_. --- ...HotReloadUnpassedInternalMemberE2ETests.cs | 45 +++++++++----- .../TransformWorkerPartialTypeTests.cs | 60 +++++++++---------- 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs index 8e0b4190d6..53d2b4b88d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs @@ -60,8 +60,9 @@ public void TearDown() VibeLogger.ClearMemoryLogs(); } - // Edits whose body runs as IL copied into the patched method. - private static IEnumerable EditsThatRunInThePatchedMethod() + // Edits whose body runs as IL copied into the patched method and uses an internal member of + // a type in the edited file's own assembly. + private static IEnumerable EditsOfTheSameAssemblyThatRunInThePatchedMethod() { yield return "InternalStaticMethod"; yield return "InternalInstanceMethod"; @@ -69,6 +70,16 @@ private static IEnumerable EditsThatRunInThePatchedMethod() yield return "InternalPropertyGet"; yield return "InternalPropertyGetAndSet"; yield return "InheritedInternalMethodThroughThis"; + } + + // Edits whose body runs as IL copied into the patched method. + private static IEnumerable EditsThatRunInThePatchedMethod() + { + foreach (string editName in EditsOfTheSameAssemblyThatRunInThePatchedMethod()) + { + yield return editName; + } + yield return "InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo"; } @@ -99,11 +110,12 @@ public async Task Run_PlainTypeBodyUsingInternalMemberOfUnpassedType_PatchesBeha } /// - /// What: the same edits on a partial type are patched, and the method returns the edited - /// value, as they are on a plain type. + /// What: the edits that use an internal member of a type in the edited file's own assembly + /// are patched on a partial type too, and the method returns the edited value, as on a plain + /// type. /// - [TestCaseSource(nameof(EditsThatRunInThePatchedMethod))] - public async Task Repro_PartialTypeBodyUsingInternalMemberOfUnpassedType_PatchesBehavior(string editName) + [TestCaseSource(nameof(EditsOfTheSameAssemblyThatRunInThePatchedMethod))] + public async Task Run_PartialTypeBodyUsingInternalMemberOfUnpassedType_PatchesBehavior(string editName) { BodyEdit edit = FindEdit(editName); HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Partial, edit); @@ -112,22 +124,23 @@ public async Task Repro_PartialTypeBodyUsingInternalMemberOfUnpassedType_Patches } /// - /// What: an edited body of a plain type that the shim cannot run as written is either - /// patched and returns the edited value, or skipped and keeps the compiled behavior. It is - /// never reported as patched and then fails when called, and never fails the file. + /// What: an edited body of a partial type that uses an internal member of a type in another + /// assembly is either patched and returns the edited value, or skipped and keeps the + /// compiled behavior. /// - [TestCaseSource(nameof(EditsThatDoNotRunInThePatchedMethod))] - public async Task Repro_PlainTypeBodyUsingInternalMemberOfUnpassedType_IsAppliedAsEditedOrSkipped(string editName) + [Test] + public async Task Run_PartialTypeBodyUsingInternalMemberOfATypeOfAnotherAssembly_IsAppliedAsEditedOrSkipped() { - BodyEdit edit = FindEdit(editName); - HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Plain, edit); + BodyEdit edit = FindEdit("InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo"); + HotReloadOrchestratorResult result = await RunEditAsync(FixtureKind.Partial, edit); - AssertAppliedAsEditedOrSkipped(result, FixtureKind.Plain, edit); + AssertAppliedAsEditedOrSkipped(result, FixtureKind.Partial, edit); } /// - /// What: the same edits on a partial type are either patched and return the edited value, - /// or skipped and keep the compiled behavior. + /// What: edits on a partial type that the shim cannot run as written are either patched and + /// return the edited value, or skipped and keep the compiled behavior. They are never + /// reported as patched and then fail when called, and never fail the file. /// [TestCaseSource(nameof(EditsThatDoNotRunInThePatchedMethod))] public async Task Run_PartialTypeBodyUsingInternalMemberOfUnpassedType_IsAppliedAsEditedOrSkipped(string editName) diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs index 54b7d14bcf..a614a76990 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs @@ -516,12 +516,11 @@ public async Task Skip_PartialTypeBodyEdit_WhenAnotherRunFileCannotBeRead_NamesT } /// - /// What: shape A of the internal-visibility regression. A body of a partial type that calls an - /// internal method of a plain type the run was not given binds, as the same call from a plain - /// type does (the control run of the same test). + /// What: a body of a partial type that calls an internal method of a plain type the run was + /// not given binds, as the same call from a plain type does (the control run of the same test). /// [Test] - public async Task Repro_PartialTypeBodyCallingInternalMethodOfUnpassedPlainType_Binds() + public async Task Run_PartialTypeBodyCallingInternalMethodOfUnpassedPlainType_Binds() { const string call = "HotReloadInternalMemberHost.InternalStaticValue()"; TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] @@ -537,12 +536,12 @@ public async Task Repro_PartialTypeBodyCallingInternalMethodOfUnpassedPlainType_ } /// - /// What: shape B of the regression with the plain file passed. A plain type's body that calls - /// an internal method of an unpassed type binds when an edit of a partial type is in the same - /// run, as it does when the plain file is passed alone (the control run of the same test). + /// What: a plain type's body that calls an internal method of an unpassed type binds when an + /// edit of a partial type is in the same run, as it does when the plain file is passed alone + /// (the control run of the same test). /// [Test] - public async Task Repro_PlainFilePassedNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() + public async Task Run_PlainFilePassedNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() { TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] { @@ -558,13 +557,12 @@ public async Task Repro_PlainFilePassedNextToPartialTypeEdit_CallingInternalMeth } /// - /// What: shape B of the regression as the field run had it: the plain file comes back as a - /// sibling to re-bind its active patches while an edit of a partial type is passed. Its body - /// that calls an internal method of an unpassed type binds, as it does when the passed edit is - /// in a plain type (the control run of the same test). + /// What: a plain file that comes back as a sibling to re-bind its active patches while an edit + /// of a partial type is passed: its body that calls an internal method of an unpassed type + /// binds, as it does when the passed edit is in a plain type (the control run of the same test). /// [Test] - public async Task Repro_PlainSiblingBroughtBackNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() + public async Task Run_PlainSiblingBroughtBackNextToPartialTypeEdit_CallingInternalMethodOfUnpassedType_Binds() { TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] { @@ -581,12 +579,12 @@ public async Task Repro_PlainSiblingBroughtBackNextToPartialTypeEdit_CallingInte } /// - /// What: shape C of the regression. A body of a partial type that calls an internal method - /// another partial type declares in a separate file the run was not given binds, as the same - /// call from a plain type does (the control run of the same test). + /// What: a body of a partial type that calls an internal method another partial type declares + /// in a separate file the run was not given binds, as the same call from a plain type does + /// (the control run of the same test). /// [Test] - public async Task Repro_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialType_Binds() + public async Task Run_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialType_Binds() { const string call = "new HotReloadPartialInternalPeer().PeerInternalValue()"; TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] @@ -602,12 +600,12 @@ public async Task Repro_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPar } /// - /// What: shape C with the other partial type's file wrapped whole in a conditional-compilation - /// block whose symbol the assembly defines: the call binds, as the same call from a plain type - /// does (the control run of the same test). + /// What: the same call, with the other partial type's file wrapped whole in a + /// conditional-compilation block whose symbol the assembly defines, binds as the same call + /// from a plain type does (the control run of the same test). /// [Test] - public async Task Repro_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialTypeInAConditionalFile_Binds() + public async Task Run_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPartialTypeInAConditionalFile_Binds() { const string call = "new HotReloadPartialInternalGuardedPeer().GuardedPeerInternalValue()"; TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] @@ -623,9 +621,9 @@ public async Task Repro_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPar } /// - /// What: the reach of the regression over member kinds. A body of a partial type that uses a - /// non-public member of a type the run was not given binds, as the same use from a plain type - /// does (the control run of the same test). + /// What: a body of a partial type that uses a non-public member of a type the run was not + /// given binds, whatever the kind of member, as the same use from a plain type does (the + /// control run of the same test). /// [TestCase("InternalInstanceMethod", "new HotReloadInternalMemberHost().InternalInstanceValue()")] [TestCase("InternalField", "new HotReloadInternalMemberHost().InternalField")] @@ -635,10 +633,7 @@ public async Task Repro_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedPar [TestCase( "PublicMemberOfInternalTypeOfAnotherAssemblyThroughInternalsVisibleTo", "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.HotReloadConstants.TestSourcesRelativeDirectory.Length")] - [TestCase( - "InternalMemberOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo", - "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointCapturedVariable.FromSnapshot(null).Name.Length")] - public async Task Repro_PartialTypeBodyUsingNonPublicMemberOfUnpassedType_Binds(string memberKind, string expression) + public async Task Run_PartialTypeBodyUsingNonPublicMemberOfUnpassedType_Binds(string memberKind, string expression) { TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] { @@ -653,14 +648,13 @@ public async Task Repro_PartialTypeBodyUsingNonPublicMemberOfUnpassedType_Binds( } /// - /// What: the reach of the regression over members a compiled base type grants its derived - /// types. A body of a partial type that uses such a member binds, as the same use from a plain - /// derived type does (the control run of the same test). + /// What: a body of a partial type that uses a member a compiled base type grants its derived + /// types binds, as the same use from a plain derived type does (the control run of the same + /// test). /// [TestCase("Protected", "ProtectedValue()")] [TestCase("PrivateProtected", "PrivateProtectedValue()")] - [TestCase("InheritedInternal", "InternalInstanceValue()")] - public async Task Repro_DerivedPartialTypeBodyUsingBaseMemberOfUnpassedType_Binds(string memberKind, string expression) + public async Task Run_DerivedPartialTypeBodyUsingBaseMemberOfUnpassedType_Binds(string memberKind, string expression) { TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] { From 80a3d43ccc365268f0498fa02e587515b98b16a8 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 23:50:43 +0900 Subject: [PATCH 04/11] Add failing tests for unpassed internal members next to partial types Hot reload should patch a partial-type body or a brought-back sibling that uses an internal member of an unpassed type in the same assembly, when the use is receiver-qualified in the method's own statements. Uses it cannot patch in place (closures, iterator or async methods, delegating shims, bare names, method groups) should stay skipped with a reason that says the member is internal, and a private member, another assembly's member, or a member only a missing part declares should keep today's reason. The fixtures gain the members these cases need. --- .../HotReloadInternalMemberCaller.cs | 6 + .../HotReload/HotReloadInternalMemberHost.cs | 18 ++ .../HotReloadPartialDerivedFixture.cs | 20 ++ .../HotReloadPartialTypeFixture.Other.cs | 6 + .../HotReload/HotReloadPartialTypeFixture.cs | 1 + .../HotReload/HotReloadPlainDerivedFixture.cs | 13 + ...HotReloadUnpassedInternalMemberE2ETests.cs | 46 ++++ .../TransformWorkerPartialTypeTests.cs | 231 ++++++++++++++++++ 8 files changed, 341 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs index a1152f7a1c..56b2b9e326 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberCaller.cs @@ -19,5 +19,11 @@ public int PlainValue() { return 1; } + + public int CallerProperty + { + [MethodImpl(MethodImplOptions.NoInlining)] + get { return 30; } + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs index bd119f14ab..d08f2c911f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs @@ -46,6 +46,24 @@ private protected int PrivateProtectedValue() { return 7; } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int PrivateStaticValue() + { + return 13; + } + + /// + /// A type nested in the host, so a test can name an internal member through the nested type. + /// + public class Nested + { + [MethodImpl(MethodImplOptions.NoInlining)] + internal static int NestedInternalValue() + { + return 11; + } + } } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs index c712126f8e..b5b1ce1fcb 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialDerivedFixture.cs @@ -37,5 +37,25 @@ public IEnumerable IteratorValues() { yield return _seed; } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedPlusValue() + { + Func read = () => this._seed; + return read() + 7; + } + + public int DerivedProperty + { + [MethodImpl(MethodImplOptions.NoInlining)] + get { return 40; } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async System.Threading.Tasks.Task AsyncValue() + { + await System.Threading.Tasks.Task.CompletedTask; + return 50; + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs index 7dd4dbce58..521d119924 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.Other.cs @@ -30,6 +30,12 @@ public int OtherPartOwnMethod() return PartialTuning - 1; } + [MethodImpl(MethodImplOptions.NoInlining)] + internal int OtherPartInternalValue() + { + return 12; + } + /// /// A type only this part declares, named in a method signature of the edited part. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs index 8845fdd6f3..549f05f63e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPartialTypeFixture.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Runtime.CompilerServices; namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload diff --git a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs index 67aa388ef4..2956f22c0a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPlainDerivedFixture.cs @@ -37,5 +37,18 @@ public IEnumerable IteratorValues() { yield return _seed; } + + [MethodImpl(MethodImplOptions.NoInlining)] + public int ClosureSeedPlusValue() + { + Func read = () => this._seed; + return read() + 7; + } + + public int DerivedProperty + { + [MethodImpl(MethodImplOptions.NoInlining)] + get { return 40; } + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs index 53d2b4b88d..0c60c6586a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs @@ -35,6 +35,8 @@ public class HotReloadUnpassedInternalMemberE2ETests private const string ClosureValue = "ClosureValue"; private const string ClosureSeedValue = "ClosureSeedValue"; private const string IteratorValues = "IteratorValues"; + private const string ClosureSeedPlusValue = "ClosureSeedPlusValue"; + private const string DerivedPropertyGetter = "get_DerivedProperty"; // Public only because a test case argument has to be as visible as the test method. public enum FixtureKind @@ -70,6 +72,10 @@ private static IEnumerable EditsOfTheSameAssemblyThatRunInThePatchedMeth yield return "InternalPropertyGet"; yield return "InternalPropertyGetAndSet"; yield return "InheritedInternalMethodThroughThis"; + yield return "InternalInstanceMethodThroughConditionalAccess"; + yield return "InternalStaticMethodOfNestedType"; + yield return "InternalFieldInsideNameof"; + yield return "InternalStaticMethodNextToLambdaReadingOwnPrivateField"; } // Edits whose body runs as IL copied into the patched method. @@ -151,6 +157,25 @@ public async Task Run_PartialTypeBodyUsingInternalMemberOfUnpassedType_IsApplied AssertAppliedAsEditedOrSkipped(result, FixtureKind.Partial, edit); } + /// + /// What: an edited getter that uses an internal member of a compiled type the reload was not + /// given is patched on a plain type and on a partial type, and the property returns the edited + /// value. + /// + [TestCase(FixtureKind.Plain)] + [TestCase(FixtureKind.Partial)] + public async Task Run_GetterUsingInternalMemberOfUnpassedType_PatchesBehavior(FixtureKind fixture) + { + BodyEdit edit = new BodyEdit( + DerivedPropertyGetter, + "return 40;", + "return HotReloadInternalMemberHost.InternalStaticValue() + 100;", + 101); + HotReloadOrchestratorResult result = await RunEditAsync(fixture, edit); + + AssertPatchedAsEdited(result, fixture, edit); + } + /// /// What: a file whose applied body uses an internal member of a type the reload was not given /// is brought back by a later reload of another file, and its method still returns the value @@ -255,6 +280,17 @@ private static BodyEdit FindEdit(string editName) 170); case "InheritedInternalMethodThroughThis": return BodyEdit.OfDerivedValue("return this.InternalInstanceValue() + 100;", 102); + case "InternalInstanceMethodThroughConditionalAccess": + return BodyEdit.OfDerivedValue( + "HotReloadInternalMemberHost host = new HotReloadInternalMemberHost();\n" + + " return (host?.InternalInstanceValue() ?? 0) + 100;", + 102); + case "InternalStaticMethodOfNestedType": + return BodyEdit.OfDerivedValue("return HotReloadInternalMemberHost.Nested.NestedInternalValue() + 100;", 111); + case "InternalFieldInsideNameof": + return BodyEdit.OfDerivedValue("return nameof(HotReloadInternalMemberHost.InternalField).Length + 100;", 113); + case "InternalStaticMethodNextToLambdaReadingOwnPrivateField": + return new BodyEdit(ClosureSeedPlusValue, "read() + 7", "read() + HotReloadInternalMemberHost.InternalStaticValue()", 1001); case "InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo": return BodyEdit.OfDerivedValue( "return global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointResponse" @@ -318,6 +354,8 @@ private static int CompiledValue(FixtureKind fixture, string methodName) return fixture == FixtureKind.Plain ? 10 : 9; case ClosureValue: return 30; + case ClosureSeedPlusValue: + return 1007; default: return 1000; } @@ -336,8 +374,12 @@ private static int CallFixture(FixtureKind fixture, string methodName) return plain.ClosureValue(); case ClosureSeedValue: return plain.ClosureSeedValue(); + case ClosureSeedPlusValue: + return plain.ClosureSeedPlusValue(); case IteratorValues: return First(plain.IteratorValues()); + case DerivedPropertyGetter: + return plain.DerivedProperty; } } else @@ -351,8 +393,12 @@ private static int CallFixture(FixtureKind fixture, string methodName) return partial.ClosureValue(); case ClosureSeedValue: return partial.ClosureSeedValue(); + case ClosureSeedPlusValue: + return partial.ClosureSeedPlusValue(); case IteratorValues: return First(partial.IteratorValues()); + case DerivedPropertyGetter: + return partial.DerivedProperty; } } diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs index a614a76990..30e22fe841 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs @@ -78,6 +78,8 @@ public class TransformWorkerPartialTypeTests private const string CallerPlainValueBody = "return 1;"; private const string PlainDerivedValueBody = "return 10;"; private const string PartialDerivedValueBody = "return 9;"; + private const string InternalMemberOfATypeOfAnotherAssembly = + "global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointCapturedVariable.FromSnapshot(null).Name.Length"; /// /// What: a body that reads a private field declared in another part of the type is emitted. @@ -654,6 +656,7 @@ public async Task Run_PartialTypeBodyUsingNonPublicMemberOfUnpassedType_Binds(st /// [TestCase("Protected", "ProtectedValue()")] [TestCase("PrivateProtected", "PrivateProtectedValue()")] + [TestCase("InheritedInternalThroughThis", "this.InternalInstanceValue()")] public async Task Run_DerivedPartialTypeBodyUsingBaseMemberOfUnpassedType_Binds(string memberKind, string expression) { TransformWorkerClientResult control = await RunWorkerOnSourcesAsync(new[] @@ -676,6 +679,203 @@ public async Task Run_DerivedPartialTypeBodyUsingBaseMemberOfUnpassedType_Binds( AssertControlAndReproEmitted(control, "DerivedValue", repro, "DerivedValue"); } + /// + /// What: a body of a partial type that uses an internal member of a type the run was not given + /// where the patched method cannot run it in place (inside a lambda, a local function or a + /// query, in an iterator or async method, as a method passed as a delegate, or by a bare name) + /// is skipped with a reason that says the member is internal, not that a part is missing. + /// + [TestCase("Lambda", "OwnOnly", null, "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] + [TestCase("LocalFunction", "OwnOnly", null, "int Read() { return HotReloadInternalMemberHost.InternalStaticValue(); } return Read();")] + [TestCase("Query", "OwnOnly", null, "return (from value in new[] { 1 } select value + HotReloadInternalMemberHost.InternalStaticValue()).First();")] + [TestCase("MethodPassedAsDelegate", "OwnOnly", null, "System.Func read = HotReloadInternalMemberHost.InternalStaticValue; return read();")] + [TestCase("Iterator", "IteratorValues", "yield return _seed;", "yield return HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("Async", "AsyncValue", "return 50;", "return HotReloadInternalMemberHost.InternalStaticValue();")] + [TestCase("BareName", "DerivedValue", PartialDerivedValueBody, "return InternalInstanceValue();")] + public async Task Skip_PartialTypeBodyUsingInternalMemberWhereItCannotBePatchedInPlace_SaysWhy( + string form, + string methodName, + string partialDerivedFragment, + string replacement) + { + string editedFileName = "InternalOutOfReach" + form + ".cs"; + TransformWorkerSourceDto edit = partialDerivedFragment == null + ? BuildFixtureOwnOnlyBodyEdit(editedFileName, replacement) + : BuildEditedFixtureSource(PartialDerivedFileName, editedFileName, partialDerivedFragment, replacement); + + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] { edit }); + + string reason = AssertSkipped(result, methodName); + Assert.That(reason, Does.Contain("is internal to 'HotReloadInternalMemberHost'"), FormatSkipped(result)); + Assert.That(reason, Does.Not.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a getter of a partial type whose lambda reads a private member, so the whole getter + /// runs through a delegating shim, is skipped with a reason that says the internal member it + /// uses outside the lambda is internal. + /// + [Test] + public async Task Skip_PartialTypeGetterWithALambdaReadingAPrivateMember_UsingInternalMemberDirectly_SaysWhy() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PartialDerivedFileName, + "InternalOutOfReachDelegatingGetter.cs", + "return 40;", + "System.Func read = () => this._seed; return read() + HotReloadInternalMemberHost.InternalStaticValue();") + }); + + Assert.That(AssertSkipped(result, "get_DerivedProperty"), Does.Contain("is internal to"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that uses a private member of a type the run was not given + /// keeps the missing-name reason. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingPrivateMemberOfUnpassedType_KeepsTheMissingNameReason() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("UnpassedPrivateMember.cs", "HotReloadInternalMemberHost.PrivateStaticValue()") + }); + + Assert.That(AssertSkipped(result, "OwnOnly"), Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that uses an internal member of a type in another assembly + /// keeps the missing-name reason. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingInternalMemberOfATypeOfAnotherAssembly_KeepsTheMissingNameReason() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("AnotherAssemblyInternalMember.cs", InternalMemberOfATypeOfAnotherAssembly) + }); + + Assert.That(AssertSkipped(result, "OwnOnly"), Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that uses an internal member of an unpassed type next to a + /// name nothing declares is skipped with a reason that names the undeclared name. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingInternalMemberNextToAnUnresolvedName_NamesTheUnresolvedName() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit( + "InternalMemberNextToUnresolvedName.cs", + "HotReloadInternalMemberHost.InternalStaticValue() + NoSuchName") + }); + + string reason = AssertSkipped(result, "OwnOnly"); + Assert.That(reason, Does.Contain("NoSuchName"), FormatSkipped(result)); + Assert.That(reason, Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: when a part of the type is in no source file the run can see, a body that calls that + /// part's internal method through 'this' keeps the missing-name reason, although the compiled + /// type declares the method. + /// + [Test] + public async Task Skip_PartialTypeBodyUsingThisInternalMemberOfAPartNotAmongTheAssemblySources_KeepsTheMissingNameReason() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync( + new[] { BuildFixtureOwnOnlyEdit("PartialThisInternalOfPartNotInSources.cs", "this.OtherPartInternalValue()") }, + assemblySourcePathsOverride: BuildAssemblySourcePathsWithout(OtherPartFileName)); + + Assert.That(AssertSkipped(result, "OwnOnly"), Does.Contain("generated at compile time"), FormatSkipped(result)); + } + + /// + /// What: a body of a partial type that calls an internal method of a type nested in a type the + /// run was not given is emitted. + /// + [Test] + public async Task Run_PartialTypeBodyUsingInternalMemberOfANestedUnpassedType_EmitsEntry() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("NestedTypeInternalMember.cs", "HotReloadInternalMemberHost.Nested.NestedInternalValue()") + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "OwnOnly"); + } + + /// + /// What: an existing getter of a partial type that calls an internal method of a type the run + /// was not given is emitted. + /// + [Test] + public async Task Run_PartialTypeGetterUsingInternalMemberOfUnpassedType_EmitsEntry() + { + TransformWorkerClientResult result = await RunEditedFixtureAsync( + "PartialGetterUsesUnpassedInternal.cs", + OtherPartPropertyGetter, + "get { return HotReloadInternalMemberHost.InternalStaticValue(); }"); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "get_ReadsOtherPartProperty"); + } + + /// + /// What: a getter of a file brought back to re-bind its active patches that calls an internal + /// method of a type the run was not given is emitted. + /// + [Test] + public async Task Run_SiblingBroughtBack_GetterUsingInternalMemberOfUnpassedType_EmitsEntry() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit("SiblingGetterPassed.cs", "2"), + AsReappliedSibling(BuildEditedFixtureSource( + CallerFileName, + "SiblingGetterCaller.cs", + "get { return 30; }", + "get { return HotReloadInternalMemberHost.InternalStaticValue(); }")) + }); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + AssertEmitted(result, "get_CallerProperty"); + } + + /// + /// What: a method of a file brought back to re-bind its active patches whose body uses an + /// internal member of an unpassed type inside a lambda keeps the sibling's skip reason. + /// + [Test] + public async Task Skip_SiblingBroughtBack_UsingInternalMemberInsideALambda_KeepsTheSiblingReason() + { + TransformWorkerClientResult result = await RunWithSiblingPlainValueAsync( + "SiblingInternalInLambda", + "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();"); + + Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); + } + + /// + /// What: a method of a file brought back to re-bind its active patches whose body uses an + /// internal member of an unpassed type next to a name nothing declares keeps the sibling's skip + /// reason. + /// + [Test] + public async Task Skip_SiblingBroughtBack_UsingInternalMemberNextToAnUnresolvedName_KeepsTheSiblingReason() + { + TransformWorkerClientResult result = await RunWithSiblingPlainValueAsync( + "SiblingInternalNextToUnresolvedName", + "return HotReloadInternalMemberHost.InternalStaticValue() + NoSuchName;"); + + Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); + } + // A fixture file next to these tests, edited once and described as a passed run source whose // snapshot is the file on disk. private static TransformWorkerSourceDto BuildEditedFixtureSource( @@ -701,6 +901,26 @@ private static TransformWorkerSourceDto BuildFixtureOwnOnlyEdit(string editedFil return BuildEditedFixtureSource(FixtureFileName, editedFileName, OwnOnlyDeclaration, editedOwnOnly); } + // The edited main part of the partial fixture, with OwnOnly's body replaced by the given line, + // which may hold several statements. + private static TransformWorkerSourceDto BuildFixtureOwnOnlyBodyEdit(string editedFileName, string bodyLine) + { + string editedOwnOnly = + " public int OwnOnly()\n {\n " + bodyLine + "\n }"; + return BuildEditedFixtureSource(FixtureFileName, editedFileName, OwnOnlyDeclaration, editedOwnOnly); + } + + // An edit of the partial fixture passed next to the plain caller, which comes back as a sibling + // whose PlainValue body is the given line. + private static Task RunWithSiblingPlainValueAsync(string label, string plainValueBody) + { + return RunWorkerOnSourcesAsync(new[] + { + BuildFixtureOwnOnlyEdit(label + "Passed.cs", "2"), + AsReappliedSibling(BuildEditedFixtureSource(CallerFileName, label + "Sibling.cs", CallerPlainValueBody, plainValueBody)) + }); + } + // The plain caller with PlainValue returning the given expression. private static TransformWorkerSourceDto BuildCallerPlainValueEdit(string editedFileName, string returnedExpression) { @@ -858,6 +1078,17 @@ private static string ReplaceOnce(string source, string fragment, string replace return source.Substring(0, first) + replacement + source.Substring(first + fragment.Length); } + // The rendered reason of the method's skipped row, once the run succeeded without an entry for + // the method. + private static string AssertSkipped(TransformWorkerClientResult result, string methodName) + { + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindEntry(result, methodName), Is.Null, methodName + " must not be applied.\n" + FormatSkipped(result)); + TransformWorkerSkippedDto skipped = FindSkipped(result, methodName); + Assert.That(skipped, Is.Not.Null, "Missing skipped row for " + methodName + ".\n" + FormatSkipped(result)); + return HotReloadWorkerReasonText.Render(skipped.reason); + } + private static TransformWorkerEntryDto AssertEmitted(TransformWorkerClientResult result, string methodName) { TransformWorkerEntryDto entry = FindEntry(result, methodName); From 767cc28018bdb406a550126b4927fd140cd10391 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 23:52:48 +0900 Subject: [PATCH 05/11] Add a reason for an internal member hot reload cannot reach in place A partial-type body that uses an internal member of a type the reload was not given, in a form the patched method cannot run (a bare name, a method group, a closure, an iterator or async method, a delegating shim), was reported as a name no part of the type declares, which sent the reader looking for a generated part. The new reason says the member is internal and which forms hot reload does patch. --- .../Editor/HotReload/HotReloadWorkerReasonTextTests.cs | 10 ++++++++++ .../HotReload/Shared/HotReloadWorkerReasonCode.cs | 1 + ...tReloadWorkerReasonText.MethodTransformTemplates.cs | 10 ++++++++++ 3 files changed, 21 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs index 86b999354d..d00a555480 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs @@ -228,6 +228,16 @@ private static IEnumerable RenderCases() + "type's source files known to hot reload declares that name: a part generated at compile " + "time is not visible to it, and a file added since the last compile must be passed with " + "--files. Otherwise run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach, + new[] { "CS0117: 'Host' does not contain a definition for 'Value'", "'Host'" }, + "CS0117: 'Host' does not contain a definition for 'Value'. That member is internal to 'Host', " + + "whose source this reload was not given. Hot reload patches a use of such a member only where " + + "it is a field, a property or a method call written with its receiver ('this.Name', " + + "'Type.Name', 'value.Name') in the method's own statements: not a bare name, a method passed " + + "as a delegate, or a use inside a lambda, local function, query, iterator or async method, or " + + "in a body patched through a delegating shim. Qualify a bare name with 'this.' or the type " + + "name, or run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.MethodTransformStructHost, NoArgs, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs index ecfc103db4..df6cec0dd1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs @@ -21,6 +21,7 @@ internal enum HotReloadWorkerReasonCode MethodTransformPartialOtherPartChanged, MethodTransformPartialOtherPartsUnverified, MethodTransformPartialBodyUnbound, + MethodTransformUnpassedInternalMemberOutOfReach, MethodTransformStructHost, MethodTransformGenericMethodOrType, MethodTransformExplicitInterfaceImplementation, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs index bd962c07e4..81bdbe7dbd 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs @@ -55,6 +55,16 @@ private static void AddMethodTransformTemplates( + "a part generated at compile time is not visible to it, and a file added since the " + "last compile must be passed with --files. Otherwise run 'uloop compile'.", 1)); + templates.Add( + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach, + Plain( + "{0}. That member is internal to {1}, whose source this reload was not given. Hot reload patches " + + "a use of such a member only where it is a field, a property or a method call written with its " + + "receiver ('this.Name', 'Type.Name', 'value.Name') in the method's own statements: not a bare " + + "name, a method passed as a delegate, or a use inside a lambda, local function, query, iterator " + + "or async method, or in a body patched through a delegating shim. Qualify a bare name with " + + "'this.' or the type name, or run 'uloop compile'.", + 2)); templates.Add( HotReloadWorkerReasonCode.MethodTransformStructHost, Plain( From ce29c57df15a970f9b24f1d76906d79e476fba1f Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 7 Oct 2026 00:00:59 +0900 Subject: [PATCH 06/11] Patch partial-type bodies that use unpassed internal members The worker's binding never imports internal members of compiled types, so such a use reads as a missing name. The partial-type guard took it for a part it cannot see, and the brought-back sibling guard skipped on any error, although the shim compile references the target assembly with every member public and binds the use. Both guards now look the name up in the target assembly with every member visible. A use of an internal member of a type the run does not declare goes through when it is a field, a property or an invoked method written with its receiver in the method's own statements. A bare name, a method group, or a use in a closure, an iterator or async method, or a delegating shim would fail the shim compile or throw at run time, so the partial-type guard skips it with the new reason, and the sibling guard keeps its reason. Other assemblies' members, private members and members only a missing part declares keep today's reason. --- .../TransformWorker~/OrdinaryMethodQueue.cs | 11 +- .../TransformWorker~/PartialTypeBodyGuard.cs | 46 ++- .../PropertyGetterClassifier.cs | 10 +- .../TransformWorker~/PropertyGetterEmitter.cs | 6 +- .../ReappliedSiblingBodyGuard.cs | 55 +++- .../UnpassedInternalMemberUse.cs | 267 ++++++++++++++++++ 6 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs index 0b263769e1..d270524907 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/OrdinaryMethodQueue.cs @@ -303,7 +303,10 @@ internal static MethodTransformDecision DecideOrdinaryMethodTransform( WorkerReason siblingSkip = ReappliedSiblingBodyGuard.DescribeSkipOrNull( semanticModel, methodBodyNode, - typeState.TargetAssembly); + typeState.TargetAssembly, + methodDeclaration, + decision, + typeState.TypeSymbol); if (siblingSkip != null) { decision = MethodTransformDecision.Skip(siblingSkip); @@ -317,7 +320,11 @@ internal static MethodTransformDecision DecideOrdinaryMethodTransform( WorkerReason partialSkip = PartialTypeBodyGuard.DescribeSkipOrNull( typeState.TypeDeclaration, semanticModel, - methodBodyNode); + methodBodyNode, + methodDeclaration, + decision, + typeState.TypeSymbol, + typeState.TargetAssembly); if (partialSkip != null) { decision = MethodTransformDecision.Skip(partialSkip); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypeBodyGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypeBodyGuard.cs index 6e01cd8d86..fab773df84 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypeBodyGuard.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PartialTypeBodyGuard.cs @@ -9,28 +9,40 @@ /// /// Skips a body on a partial type that names something no visible part declares, instead of /// letting it fail the whole file in the shim compile: a part generated at compile time is -/// invisible to the worker, so the name may be perfectly valid. +/// invisible to the worker, so the name may be perfectly valid. An internal member of a compiled +/// type the run was not given looks just as missing to the worker; such a use goes through when +/// the patched method runs it itself, and is skipped with a reason that says why otherwise. /// internal static class PartialTypeBodyGuard { // Why only these: other binding errors are expected (a compiled API still expects the // compiled copy of a type the run declares from source) and the shim compile settles - // them. An unresolved name is what a missing part looks like, and it cannot compile - // in the shim either. + // them. An unresolved name is what a missing part looks like, and it cannot compile in the + // shim either, except for an internal member of a compiled type of the target assembly, + // which the shim compile sees. private static readonly HashSet UnresolvedNameDiagnosticIds = new HashSet(StringComparer.Ordinal) { "CS0103", "CS1061", "CS0117", "CS0246" }; - /// The skip reason for the body, or null when it names nothing unresolved or the type is not partial. + /// + /// The skip reason for the body, or null when the type is not partial, or when the body names + /// nothing unresolved apart from internal members the patched method can reach. + /// internal static WorkerReason DescribeSkipOrNull( TypeDeclarationSyntax typeDeclaration, SemanticModel semanticModel, - SyntaxNode bodyNode) + SyntaxNode bodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol typeSymbol, + IAssemblySymbol targetAssembly) { if (bodyNode == null || !PartialTypeParts.IsPartialOrNestedInPartial(typeDeclaration)) { return null; } + // Why every error and not the first: an internal member the patched method can reach says + // nothing about the next error, which may be a name nothing declares. foreach (Diagnostic diagnostic in semanticModel.GetDiagnostics(bodyNode.Span)) { if (diagnostic.Severity != DiagnosticSeverity.Error) @@ -43,9 +55,29 @@ internal static WorkerReason DescribeSkipOrNull( continue; } + string diagnosticText = diagnostic.Id + ": " + diagnostic.GetMessage(CultureInfo.InvariantCulture); + UnpassedInternalMemberUse use = UnpassedInternalMemberUse.FindOrNull( + diagnostic, + semanticModel, + bodyNode, + methodDeclarationOrNull, + decision, + typeSymbol, + targetAssembly); + if (use == null) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.MethodTransformPartialBodyUnbound, diagnosticText); + } + + if (use.CanBePatchedInPlace) + { + continue; + } + return WorkerReason.Of( - HotReloadWorkerReasonCode.MethodTransformPartialBodyUnbound, - diagnostic.Id + ": " + diagnostic.GetMessage(CultureInfo.InvariantCulture)); + HotReloadWorkerReasonCode.MethodTransformUnpassedInternalMemberOutOfReach, + diagnosticText, + "'" + use.DeclaringType.Name + "'"); } return null; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs index f5414c2d5c..d9c50ae260 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterClassifier.cs @@ -69,6 +69,7 @@ internal static (bool SkipGetter, MethodTransformDecision Decision) TrySkipPrope AddedFieldCatalog addedFieldCatalog, AddedPropertyCatalog addedPropertyCatalog, PartialTypeParts partialTypeParts, + IAssemblySymbol targetAssembly, List skipped) { MethodTransformDecision decision = MethodTransformDecider.DecideMethodTransform( @@ -93,7 +94,14 @@ internal static (bool SkipGetter, MethodTransformDecision Decision) TrySkipPrope return (true, decision); } - WorkerReason partialSkip = PartialTypeBodyGuard.DescribeSkipOrNull(typeDeclaration, semanticModel, getterBodyNode); + WorkerReason partialSkip = PartialTypeBodyGuard.DescribeSkipOrNull( + typeDeclaration, + semanticModel, + getterBodyNode, + methodDeclarationOrNull: null, + decision, + typeSymbol, + targetAssembly); if (partialSkip != null) { skipped.Add(new WorkerSkipped diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs index 68376d388f..202bf8802b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/PropertyGetterEmitter.cs @@ -216,6 +216,7 @@ internal static ShimTypeBuilder AppendPropertyGetterEntry( addedFieldCatalog, addedPropertyCatalog, partialTypeParts, + targetAssembly, skipped); if (skipGetter) { @@ -230,7 +231,10 @@ internal static ShimTypeBuilder AppendPropertyGetterEntry( WorkerReason siblingSkip = ReappliedSiblingBodyGuard.DescribeSkipOrNull( semanticModel, getterBodyNode, - targetAssembly); + targetAssembly, + methodDeclarationOrNull: null, + decision, + typeSymbol); if (siblingSkip != null) { skipped.Add(new WorkerSkipped diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs index 23fdef6758..d198d7422a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ReappliedSiblingBodyGuard.cs @@ -7,7 +7,9 @@ /// /// Skips an existing method of a file the run pulled in to re-bind its active patches when that -/// method's body no longer binds, and names the file the reader can pass so it binds again. +/// method's body no longer binds, and names the file the reader can pass so it binds again. A body +/// whose only errors are internal members of unpassed compiled types that the patched method +/// reaches itself goes through, because the shim compile binds them. /// /// /// Why only such files: the binding guard runs for added methods alone, so an existing body @@ -22,7 +24,10 @@ internal static class ReappliedSiblingBodyGuard internal static WorkerReason DescribeSkipOrNull( SemanticModel semanticModel, SyntaxNode methodBodyNode, - IAssemblySymbol targetAssembly) + IAssemblySymbol targetAssembly, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol typeSymbol) { Diagnostic bindingError = AddedMemberBindingGuard.FindFirstBindingError(semanticModel, methodBodyNode); if (bindingError == null) @@ -30,6 +35,17 @@ internal static WorkerReason DescribeSkipOrNull( return null; } + if (EveryErrorCanBePatchedInPlace( + semanticModel, + methodBodyNode, + methodDeclarationOrNull, + decision, + typeSymbol, + targetAssembly)) + { + return null; + } + string diagnosticText = bindingError.Id + ": " + bindingError.GetMessage(CultureInfo.InvariantCulture); INamedTypeSymbol receiver = FindCompiledReceiverOfUnboundMember(semanticModel, methodBodyNode, targetAssembly); if (receiver == null) @@ -44,6 +60,41 @@ internal static WorkerReason DescribeSkipOrNull( "'" + receiver.Name + "'"); } + // Why every error has to qualify, and the skip keeps today's reason otherwise: one other error + // still fails the file in the shim, or a use out of the patched method's reach throws once + // called, and the first error is what the reason has always named. + private static bool EveryErrorCanBePatchedInPlace( + SemanticModel semanticModel, + SyntaxNode methodBodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol typeSymbol, + IAssemblySymbol targetAssembly) + { + foreach (Diagnostic diagnostic in semanticModel.GetDiagnostics(methodBodyNode.Span)) + { + if (diagnostic.Severity != DiagnosticSeverity.Error) + { + continue; + } + + UnpassedInternalMemberUse use = UnpassedInternalMemberUse.FindOrNull( + diagnostic, + semanticModel, + methodBodyNode, + methodDeclarationOrNull, + decision, + typeSymbol, + targetAssembly); + if (use == null || !use.CanBePatchedInPlace) + { + return false; + } + } + + return true; + } + // Why not CompiledSignatureSplitCollector: it names a compiled API whose signature still takes // the compiled copy of a type this run declares, while here the member itself is missing from // the compiled type, so there is no split for it to find. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs new file mode 100644 index 0000000000..f05a46ea1a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// A use, inside a method body, of an internal member declared by a compiled type of the target +/// assembly that the run does not declare: the worker's binding reports the member as missing, +/// while the shim compile binds it and the patched method can call it. +/// +/// +/// Why the worker reports such a member as missing rather than inaccessible: its binding +/// compilation imports a referenced assembly through the public surface only, so an internal +/// member of a compiled type is not there at all. The shim compile references a copy of the +/// target assembly with every member made public. +/// +internal sealed class UnpassedInternalMemberUse +{ + private static readonly HashSet MemberNotFoundDiagnosticIds = + new HashSet(StringComparer.Ordinal) { "CS0103", "CS1061", "CS0117" }; + + private UnpassedInternalMemberUse(INamedTypeSymbol declaringType, bool canBePatchedInPlace) + { + DeclaringType = declaringType; + CanBePatchedInPlace = canBePatchedInPlace; + } + + /// The compiled type that declares the member, read with every member visible. + internal INamedTypeSymbol DeclaringType { get; } + + /// True when the patched method itself runs the use, so a guard may let the body through. + internal bool CanBePatchedInPlace { get; } + + /// + /// The use reports, or null when the error is not a missing member that + /// turns out to be an internal member of a compiled type of the target assembly the run does + /// not declare. + /// + internal static UnpassedInternalMemberUse FindOrNull( + Diagnostic error, + SemanticModel semanticModel, + SyntaxNode bodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision, + INamedTypeSymbol enclosingType, + IAssemblySymbol targetAssembly) + { + Debug.Assert(error != null, "error must not be null."); + Debug.Assert(semanticModel != null, "semanticModel must not be null."); + Debug.Assert(bodyNode != null, "bodyNode must not be null."); + Debug.Assert(decision != null, "decision must not be null."); + Debug.Assert(enclosingType != null, "enclosingType must not be null."); + + if (targetAssembly == null || !MemberNotFoundDiagnosticIds.Contains(error.Id)) + { + return null; + } + + SimpleNameSyntax name = FindReportedNameOrNull(error, bodyNode); + if (name == null) + { + return null; + } + + bool hasReceiver = HasReceiver(name); + INamedTypeSymbol start = hasReceiver ? FindReceiverTypeOrNull(name, semanticModel) : enclosingType; + if (start == null) + { + return null; + } + + ISymbol member = FindInternalMemberOfUnpassedTypeOrNull( + start, + name.Identifier.ValueText, + semanticModel, + targetAssembly); + if (member == null) + { + return null; + } + + // Why a bare name is out of reach: the shim is a static method, and it qualifies a bare + // member name only when the worker binds the name. This member never binds there, so the + // name would reach the shim compile unqualified and fail the whole file. + bool canBePatchedInPlace = hasReceiver + && IsPatchableKind(member, name) + && !RunsOutsideThePatchedMethod(name, bodyNode, methodDeclarationOrNull, decision); + return new UnpassedInternalMemberUse(member.ContainingType, canBePatchedInPlace); + } + + private static SimpleNameSyntax FindReportedNameOrNull(Diagnostic error, SyntaxNode bodyNode) + { + Location location = error.Location; + if (!location.IsInSource + || location.SourceTree != bodyNode.SyntaxTree + || !bodyNode.Span.Contains(location.SourceSpan)) + { + return null; + } + + // Why a member binding is unwrapped: for 'value?.Name' the compiler reports the whole + // '.Name' binding, while for 'value.Name' and a bare name it reports the name itself. + SyntaxNode reported = bodyNode.FindNode(location.SourceSpan, getInnermostNodeForTie: true); + if (reported is MemberBindingExpressionSyntax binding) + { + return binding.Name; + } + + return reported as SimpleNameSyntax; + } + + // 'value.Name', 'Type.Name' and 'this.Name', or 'value?.Name'. + private static bool HasReceiver(SimpleNameSyntax name) + { + if (name.Parent is MemberAccessExpressionSyntax access) + { + return access.Name == name; + } + + return name.Parent is MemberBindingExpressionSyntax binding && binding.Name == name; + } + + private static INamedTypeSymbol FindReceiverTypeOrNull(SimpleNameSyntax name, SemanticModel semanticModel) + { + ExpressionSyntax receiver = name.Parent is MemberAccessExpressionSyntax access + ? access.Expression + : name.FirstAncestorOrSelf()?.Expression; + if (receiver == null) + { + return null; + } + + // Why not a type parameter, pointer, dynamic or Nullable receiver: the member is not + // looked up on a compiled type of the target assembly through any of them. + INamedTypeSymbol type = semanticModel.GetTypeInfo(receiver).Type as INamedTypeSymbol; + if (type == null || type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return null; + } + + return type; + } + + private static ISymbol FindInternalMemberOfUnpassedTypeOrNull( + INamedTypeSymbol start, + string memberName, + SemanticModel semanticModel, + IAssemblySymbol targetAssembly) + { + INamedTypeSymbol compiled = FindInTargetAssemblyOrNull(start.OriginalDefinition, semanticModel, targetAssembly); + for (INamedTypeSymbol type = compiled; type != null; type = type.BaseType?.OriginalDefinition) + { + // Why only types of the target assembly: the shim compile makes every member public in + // the target assembly and in some project assemblies, and the worker cannot tell which + // others. The full import shows the internal members of another assembly's base type too. + if (!IsOfAssembly(type, targetAssembly)) + { + continue; + } + + // Why skip a type the run declares: its members come from the source parts the run + // reads, so a member missing there is a part the worker cannot see, which is what the + // missing-name reason explains. Its compiled copy would still list that member. + if (IsDeclaredByTheRun(type, semanticModel)) + { + continue; + } + + // Why internal only: another type's private member is a use the real compiler rejects + // too, and protected or protected internal members already bind in the worker. + foreach (ISymbol member in type.GetMembers(memberName)) + { + if (member.DeclaredAccessibility == Accessibility.Internal) + { + return member; + } + } + } + + return null; + } + + // Why names and not symbols: the target assembly comes from a separate compilation that + // imports every member, so its types are other symbols than the ones this body binds to. + private static INamedTypeSymbol FindInTargetAssemblyOrNull( + INamedTypeSymbol type, + SemanticModel semanticModel, + IAssemblySymbol targetAssembly) + { + IAssemblySymbol owner = type.ContainingAssembly; + if (owner == null) + { + return null; + } + + bool declaredByTheRun = owner.Identity.Equals(semanticModel.Compilation.Assembly.Identity); + if (!declaredByTheRun && !owner.Identity.Equals(targetAssembly.Identity)) + { + return null; + } + + return targetAssembly.GetTypeByMetadataName(ConstDriftCollector.ToReflectionMetadataName(type)); + } + + private static bool IsOfAssembly(INamedTypeSymbol type, IAssemblySymbol assembly) + { + return type.ContainingAssembly != null && type.ContainingAssembly.Identity.Equals(assembly.Identity); + } + + // Why the compilation's own assembly: it holds only the types the run declares from source, + // while Compilation.GetTypeByMetadataName would also find every referenced type. + private static bool IsDeclaredByTheRun(INamedTypeSymbol type, SemanticModel semanticModel) + { + return semanticModel.Compilation.Assembly.GetTypeByMetadataName(ConstDriftCollector.ToReflectionMetadataName(type)) != null; + } + + // Why only fields, properties and invoked methods: those are the uses a run has shown to bind + // in the shim and to work once patched. A method passed as a delegate and an event have not + // been run that way, so they stay skipped. + private static bool IsPatchableKind(ISymbol member, SimpleNameSyntax name) + { + if (member is IFieldSymbol || member is IPropertySymbol) + { + return true; + } + + return member is IMethodSymbol method && method.MethodKind == MethodKind.Ordinary && IsInvoked(name); + } + + private static bool IsInvoked(SimpleNameSyntax name) + { + SyntaxNode callee = HasReceiver(name) ? name.Parent : name; + return callee.Parent is InvocationExpressionSyntax invocation && invocation.Expression == callee; + } + + // Why these places are out of reach: a closure body, an async or iterator state machine, and a + // delegating shim run as ordinary code of the shim assembly, which the runtime checks for + // access, so a call to an internal member there throws MethodAccessException after the run + // reported success. Only the statements copied into the patched method skip that check. + private static bool RunsOutsideThePatchedMethod( + SyntaxNode name, + SyntaxNode bodyNode, + MethodDeclarationSyntax methodDeclarationOrNull, + MethodTransformDecision decision) + { + if (decision.UsesDelegation) + { + return true; + } + + if (MethodTransformDecider.IsAsyncOrIterator(methodDeclarationOrNull, bodyNode)) + { + return true; + } + + foreach (SyntaxNode closureBody in MethodTransformDecider.FindClosureBodies(bodyNode)) + { + if (closureBody.Span.Contains(name.Span)) + { + return true; + } + } + + return false; + } +} From 21048ce017216249e2c289e76572ed1db91d1a8c Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 7 Oct 2026 00:24:21 +0900 Subject: [PATCH 07/11] Document which internal-member uses a partial-type body can patch The skill reference now tells readers that hot reload patches an internal member of a type the reload was not given only as a field, a property or a method call written with its receiver in the method's own statements, and that another assembly's internal members still stay skipped from a partial type. The generated skill copies are regenerated from the source. --- .agents/skills/uloop-hot-reload/references/scope-and-limits.md | 2 ++ .claude/skills/uloop-hot-reload/references/scope-and-limits.md | 2 ++ .../HotReload/Skill/references/scope-and-limits.md | 2 ++ 3 files changed, 6 insertions(+) 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 eda3049cbc..3d7715134c 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -404,6 +404,8 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | | Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | | Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | | Explicit interface implementation | Dotted metadata names cannot be expressed as shim identifiers | 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 eda3049cbc..3d7715134c 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -404,6 +404,8 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | | Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | | Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | | Explicit interface implementation | Dotted metadata names cannot be expressed as shim identifiers | 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 eda3049cbc..3d7715134c 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 @@ -404,6 +404,8 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | | Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | | Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | | Explicit interface implementation | Dotted metadata names cannot be expressed as shim identifiers | From 3d14ed116c234be467831b1dd3eb502d20d8f7ec Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 7 Oct 2026 00:58:43 +0900 Subject: [PATCH 08/11] Add failing tests for closures over an internal member's result The worker reports no error for a use that follows a value it could not bind, so a lambda, local function or query that works with the result of an internal member of an unpassed type passes both guards today, and the patched method throws MethodAccessException or FieldAccessException once the closure runs in the shim assembly. The new cases cover that body in a partial-type method, a getter and a brought-back file, end to end for a lambda over a local and a lambda parameter, and pin that a use of the result in the method's own statements is still patched. --- .../HotReload/HotReloadInternalMemberHost.cs | 12 +++++ ...HotReloadUnpassedInternalMemberE2ETests.cs | 18 +++++++- .../TransformWorkerPartialTypeTests.cs | 44 ++++++++++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs index d08f2c911f..2ae28ebefb 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadInternalMemberHost.cs @@ -53,6 +53,18 @@ private static int PrivateStaticValue() return 13; } + [MethodImpl(MethodImplOptions.NoInlining)] + internal static HotReloadInternalMemberHost InternalSelf() + { + return new HotReloadInternalMemberHost(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static HotReloadInternalMemberHost[] InternalHosts() + { + return new HotReloadInternalMemberHost[] { new HotReloadInternalMemberHost() }; + } + /// /// A type nested in the host, so a test can name an internal member through the nested type. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs index 0c60c6586a..41cbbd3af6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadUnpassedInternalMemberE2ETests.cs @@ -76,6 +76,7 @@ private static IEnumerable EditsOfTheSameAssemblyThatRunInThePatchedMeth yield return "InternalStaticMethodOfNestedType"; yield return "InternalFieldInsideNameof"; yield return "InternalStaticMethodNextToLambdaReadingOwnPrivateField"; + yield return "InternalInstanceMethodOfAnInternalResult"; } // Edits whose body runs as IL copied into the patched method. @@ -90,7 +91,8 @@ private static IEnumerable EditsThatRunInThePatchedMethod() } // Edits the shim cannot run as written: a simple name it cannot qualify, or a use inside a - // lambda or iterator, which the shim assembly compiles as ordinary code of its own. + // lambda or iterator, which the shim assembly compiles as ordinary code of its own, also when + // the lambda reaches the member through the result of another internal member. private static IEnumerable EditsThatDoNotRunInThePatchedMethod() { yield return "InheritedInternalMethodBySimpleName"; @@ -100,6 +102,8 @@ private static IEnumerable EditsThatDoNotRunInThePatchedMethod() yield return "InternalStaticMethodInIteratorReadingOwnPrivateField"; yield return "InternalInstanceMethodInIteratorReadingOwnPrivateField"; yield return "InternalStaticMethodInIterator"; + yield return "InternalInstanceMethodInLambdaOverAnInternalResult"; + yield return "InternalFieldInLambdaParameterFromAnInternalResult"; } /// @@ -291,6 +295,8 @@ private static BodyEdit FindEdit(string editName) return BodyEdit.OfDerivedValue("return nameof(HotReloadInternalMemberHost.InternalField).Length + 100;", 113); case "InternalStaticMethodNextToLambdaReadingOwnPrivateField": return new BodyEdit(ClosureSeedPlusValue, "read() + 7", "read() + HotReloadInternalMemberHost.InternalStaticValue()", 1001); + case "InternalInstanceMethodOfAnInternalResult": + return BodyEdit.OfDerivedValue("return HotReloadInternalMemberHost.InternalSelf().InternalInstanceValue() + 120;", 122); case "InternalMethodOfPublicTypeOfAnotherAssemblyThroughInternalsVisibleTo": return BodyEdit.OfDerivedValue( "return global::io.github.hatayama.UnityCliLoop.FirstPartyTools.PausePointResponse" @@ -310,6 +316,16 @@ private static BodyEdit FindEdit(string editName) return new BodyEdit(IteratorValues, "yield return _seed;", "yield return _seed + new HotReloadInternalMemberHost().InternalInstanceValue();", 1002); case "InternalStaticMethodInIterator": return new BodyEdit(IteratorValues, "yield return _seed;", "yield return HotReloadInternalMemberHost.InternalStaticValue() + 100;", 101); + case "InternalInstanceMethodInLambdaOverAnInternalResult": + return BodyEdit.OfDerivedValue( + "var host = HotReloadInternalMemberHost.InternalSelf();\n" + + " System.Func read = () => host.InternalInstanceValue() + 100;\n" + + " return read();", + 102); + case "InternalFieldInLambdaParameterFromAnInternalResult": + return BodyEdit.OfDerivedValue( + "return System.Array.Exists(HotReloadInternalMemberHost.InternalHosts(), host => host.InternalField > 0) ? 100 : 0;", + 100); default: throw new ArgumentException("Unknown edit: " + editName); } diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs index 30e22fe841..a36ab6f650 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerPartialTypeTests.cs @@ -631,6 +631,7 @@ public async Task Run_PartialTypeBodyCallingInternalMethodOfAnotherUnpassedParti [TestCase("InternalField", "new HotReloadInternalMemberHost().InternalField")] [TestCase("InternalProperty", "new HotReloadInternalMemberHost().InternalProperty")] [TestCase("InternalType", "HotReloadInternalOnlyType.Value()")] + [TestCase("InternalMethodOfAnInternalResult", "HotReloadInternalMemberHost.InternalSelf().InternalInstanceValue()")] [TestCase("ProtectedInternalMethod", "new HotReloadInternalMemberHost().ProtectedInternalValue()")] [TestCase( "PublicMemberOfInternalTypeOfAnotherAssemblyThroughInternalsVisibleTo", @@ -682,12 +683,17 @@ public async Task Run_DerivedPartialTypeBodyUsingBaseMemberOfUnpassedType_Binds( /// /// What: a body of a partial type that uses an internal member of a type the run was not given /// where the patched method cannot run it in place (inside a lambda, a local function or a - /// query, in an iterator or async method, as a method passed as a delegate, or by a bare name) - /// is skipped with a reason that says the member is internal, not that a part is missing. + /// query, in an iterator or async method, as a method passed as a delegate, by a bare name, or + /// next to a lambda, local function or query that works with the member's result) is skipped + /// with a reason that says the member is internal, not that a part is missing. /// [TestCase("Lambda", "OwnOnly", null, "System.Func read = () => HotReloadInternalMemberHost.InternalStaticValue(); return read();")] [TestCase("LocalFunction", "OwnOnly", null, "int Read() { return HotReloadInternalMemberHost.InternalStaticValue(); } return Read();")] [TestCase("Query", "OwnOnly", null, "return (from value in new[] { 1 } select value + HotReloadInternalMemberHost.InternalStaticValue()).First();")] + [TestCase("LambdaUsingTheResult", "OwnOnly", null, "var host = HotReloadInternalMemberHost.InternalSelf(); System.Func read = () => host.InternalInstanceValue(); return read();")] + [TestCase("LambdaParameterFromTheResult", "OwnOnly", null, "return System.Array.Exists(HotReloadInternalMemberHost.InternalHosts(), host => host.InternalField > 0) ? 1 : 0;")] + [TestCase("LocalFunctionUsingTheResult", "OwnOnly", null, "var host = HotReloadInternalMemberHost.InternalSelf(); int Read() { return host.InternalInstanceValue(); } return Read();")] + [TestCase("QueryOverTheResult", "OwnOnly", null, "var hosts = HotReloadInternalMemberHost.InternalHosts(); return (from host in hosts select host.InternalField).First();")] [TestCase("MethodPassedAsDelegate", "OwnOnly", null, "System.Func read = HotReloadInternalMemberHost.InternalStaticValue; return read();")] [TestCase("Iterator", "IteratorValues", "yield return _seed;", "yield return HotReloadInternalMemberHost.InternalStaticValue();")] [TestCase("Async", "AsyncValue", "return 50;", "return HotReloadInternalMemberHost.InternalStaticValue();")] @@ -730,6 +736,26 @@ public async Task Skip_PartialTypeGetterWithALambdaReadingAPrivateMember_UsingIn Assert.That(AssertSkipped(result, "get_DerivedProperty"), Does.Contain("is internal to"), FormatSkipped(result)); } + /// + /// What: a getter of a partial type whose lambda works with the result of an internal member + /// of a type the run was not given is skipped with the internal-member reason, as a method + /// with the same body is. + /// + [Test] + public async Task Skip_PartialTypeGetterWithALambdaUsingTheResultOfAnInternalMember_SaysWhy() + { + TransformWorkerClientResult result = await RunWorkerOnSourcesAsync(new[] + { + BuildEditedFixtureSource( + PartialDerivedFileName, + "InternalOutOfReachGetterLambdaUsingTheResult.cs", + "return 40;", + "var host = HotReloadInternalMemberHost.InternalSelf(); System.Func read = () => host.InternalInstanceValue(); return read();") + }); + + Assert.That(AssertSkipped(result, "get_DerivedProperty"), Does.Contain("is internal to"), FormatSkipped(result)); + } + /// /// What: a body of a partial type that uses a private member of a type the run was not given /// keeps the missing-name reason. @@ -861,6 +887,20 @@ public async Task Skip_SiblingBroughtBack_UsingInternalMemberInsideALambda_Keeps Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); } + /// + /// What: a method of a file brought back to re-bind its active patches whose lambda works with + /// the result of an internal member of an unpassed type keeps the sibling's skip reason. + /// + [Test] + public async Task Skip_SiblingBroughtBack_WithALambdaUsingTheResultOfAnInternalMember_KeepsTheSiblingReason() + { + TransformWorkerClientResult result = await RunWithSiblingPlainValueAsync( + "SiblingLambdaUsingTheResult", + "var host = HotReloadInternalMemberHost.InternalSelf(); System.Func read = () => host.InternalInstanceValue(); return read();"); + + Assert.That(AssertSkipped(result, "PlainValue"), Does.Contain("brought back to re-bind"), FormatSkipped(result)); + } + /// /// What: a method of a file brought back to re-bind its active patches whose body uses an /// internal member of an unpassed type next to a name nothing declares keeps the sibling's skip From 4ee32790d2d53caf3278e21bdc9752b429cab845 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 7 Oct 2026 01:00:16 +0900 Subject: [PATCH 09/11] Keep bodies whose closures use an unresolved value out of reach An internal member's result is a value the worker cannot bind, and it reports no error for a use that follows it, so it cannot tell whether a lambda, local function or query reaches an internal member through that value. Such a closure runs in the shim assembly, where the use throws once called. An internal-member use now counts as patchable in place only when no closure in the body has an expression whose type the worker could not resolve. The method's own statements still go through, because they run inside the patched method. --- .../UnpassedInternalMemberUse.cs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs index f05a46ea1a..1a4ba1d812 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/UnpassedInternalMemberUse.cs @@ -85,7 +85,8 @@ internal static UnpassedInternalMemberUse FindOrNull( // name would reach the shim compile unqualified and fail the whole file. bool canBePatchedInPlace = hasReceiver && IsPatchableKind(member, name) - && !RunsOutsideThePatchedMethod(name, bodyNode, methodDeclarationOrNull, decision); + && !RunsOutsideThePatchedMethod(name, bodyNode, methodDeclarationOrNull, decision) + && !HasAClosureOverAnUnresolvedValue(bodyNode, semanticModel); return new UnpassedInternalMemberUse(member.ContainingType, canBePatchedInPlace); } @@ -264,4 +265,27 @@ private static bool RunsOutsideThePatchedMethod( return false; } + + // Why a closure over a value the worker could not resolve keeps every use in the body out: the + // worker reports no error for a use that follows such a value, like the result of an internal + // member, so it cannot tell whether the closure reaches an internal member through it. A closure + // runs as ordinary code of the shim assembly, where that use throws once called. The method's + // own statements run inside the patched method, where a following internal use works, so only + // closures are checked. + private static bool HasAClosureOverAnUnresolvedValue(SyntaxNode bodyNode, SemanticModel semanticModel) + { + foreach (SyntaxNode closureBody in MethodTransformDecider.FindClosureBodies(bodyNode)) + { + foreach (SyntaxNode node in closureBody.DescendantNodesAndSelf()) + { + if (node is ExpressionSyntax expression + && semanticModel.GetTypeInfo(expression).Type?.TypeKind == TypeKind.Error) + { + return true; + } + } + } + + return false; + } } From e2c019593b9b7895eee81511e89b4e7692d412a4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 7 Oct 2026 01:01:36 +0900 Subject: [PATCH 10/11] Say why a closure over an internal member's result keeps a body out The out-of-reach reason listed only where the member's own use sits, so a body skipped because a lambda, local function or query works with the member's result read as patchable. The reason now says that such a closure keeps the whole body out as well. --- .../Editor/HotReload/HotReloadWorkerReasonTextTests.cs | 5 +++-- .../HotReloadWorkerReasonText.MethodTransformTemplates.cs | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs index d00a555480..2171a81e93 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs @@ -236,8 +236,9 @@ private static IEnumerable RenderCases() + "it is a field, a property or a method call written with its receiver ('this.Name', " + "'Type.Name', 'value.Name') in the method's own statements: not a bare name, a method passed " + "as a delegate, or a use inside a lambda, local function, query, iterator or async method, or " - + "in a body patched through a delegating shim. Qualify a bare name with 'this.' or the type " - + "name, or run 'uloop compile'."); + + "in a body patched through a delegating shim. A lambda, local function or query that works " + + "with a value hot reload could not resolve, such as the member's result, keeps the whole body " + + "out as well. Qualify a bare name with 'this.' or the type name, or run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.MethodTransformStructHost, NoArgs, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs index 81bdbe7dbd..2d77bcc13d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.MethodTransformTemplates.cs @@ -62,8 +62,10 @@ private static void AddMethodTransformTemplates( + "a use of such a member only where it is a field, a property or a method call written with its " + "receiver ('this.Name', 'Type.Name', 'value.Name') in the method's own statements: not a bare " + "name, a method passed as a delegate, or a use inside a lambda, local function, query, iterator " - + "or async method, or in a body patched through a delegating shim. Qualify a bare name with " - + "'this.' or the type name, or run 'uloop compile'.", + + "or async method, or in a body patched through a delegating shim. A lambda, local function or " + + "query that works with a value hot reload could not resolve, such as the member's result, keeps " + + "the whole body out as well. Qualify a bare name with 'this.' or the type name, or run " + + "'uloop compile'.", 2)); templates.Add( HotReloadWorkerReasonCode.MethodTransformStructHost, From 9f583d8f2d767ae5ee0ac903546043d07a347962 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 7 Oct 2026 01:02:02 +0900 Subject: [PATCH 11/11] Document closures over an internal member's result as skipped The skill reference now lists a body where a lambda, local function or query works with the result of an internal member among the uses hot reload skips from a partial type. The generated skill copies are regenerated from the source. --- .agents/skills/uloop-hot-reload/references/scope-and-limits.md | 2 +- .claude/skills/uloop-hot-reload/references/scope-and-limits.md | 2 +- .../HotReload/Skill/references/scope-and-limits.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 3d7715134c..5553989c40 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -404,7 +404,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | | Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | | Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | -| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, in a body where a lambda, local function or query works with the member's result, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | | Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | 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 3d7715134c..5553989c40 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -404,7 +404,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | | Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | | Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | -| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, in a body where a lambda, local function or query works with the member's result, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | | Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics | 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 3d7715134c..5553989c40 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 @@ -404,7 +404,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | Method on a `partial` type when another part of the type changed since the last compile and was not passed, or when a file that names the type has syntax errors (passed or not) | Hot reload binds against the compiled type; pass that file with `--files` too, or run `uloop compile`. For a file with syntax errors, fix it and run hot reload again | | Method on a `partial` type when the other parts could not be checked against the last compile (no source snapshot yet, or more than 50 changed files in the assembly) | Run `uloop compile` | | Method on a `partial` type whose body names a member no source file of the assembly declares | A part generated at compile time (a source generator's output) is not visible to hot reload; run `uloop compile` | -| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | +| Method or getter on a `partial` type whose body uses an `internal` member of a type the reload was not given, by its bare name, as a method passed as a delegate, inside a lambda, local function, query, iterator or async method, in a body where a lambda, local function or query works with the member's result, or in a body patched through a delegating shim | Hot reload reaches such a member only as a field, a property or a method call in the method's own statements, written with its receiver (`this.Name`, `Type.Name`, `value.Name`); qualify a bare name, or run `uloop compile` | | Method or getter on a `partial` type whose body uses an `internal` member of a type in another assembly (through `InternalsVisibleTo`) | Reported as a name no source file of the `partial` type declares; hot reload does not patch this use from a `partial` type yet. Run `uloop compile` | | Method on a struct (value type) | Value-type patching is out of scope | | Generic method, or method on a generic type | Harmony cannot safely patch open generics |