From 5ead3cf936b364564a275f45d27872cea548dd37 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 30 Sep 2026 19:29:31 -0300 Subject: [PATCH 1/6] Constrain behavior extension methods to IStunt AddBehavior/InsertBehavior were duplicated as IStunt and unconstrained generic overloads, with the generic ones unwrapping a delegate target at runtime and throwing ArgumentException when the receiver was not a stunt. Keep only the generic overloads and constrain TStunt to IStunt, so the API is discoverable only on stunts, the receiver type is preserved for chaining, and misuse is a compile error instead of a runtime throw. --- src/Stunts.UnitTests/StuntExtensionsTests.cs | 115 ++++++------------- src/Stunts/StuntExtensions.cs | 102 +++------------- 2 files changed, 51 insertions(+), 166 deletions(-) diff --git a/src/Stunts.UnitTests/StuntExtensionsTests.cs b/src/Stunts.UnitTests/StuntExtensionsTests.cs index bed8b55..e359368 100644 --- a/src/Stunts.UnitTests/StuntExtensionsTests.cs +++ b/src/Stunts.UnitTests/StuntExtensionsTests.cs @@ -40,75 +40,49 @@ public void AddBehavior() } [Fact] - public void AddAnonymousBehaviorToObject() + public void AddBehaviorPreservesTheStuntType() { - object stunt = new TestStunt(); - Func method = ToString; + var stunt = new StuntReference(new TestStunt()); - var actual = stunt.AddBehavior( - (m, n) => new MethodReturn(m, "foo", null!), - m => true, - nameof(AddBehavior)) as IStunt; + // The concrete reference type flows through, so behaviors can keep being configured. + StuntReference actual = stunt.AddBehavior(new TestBehavior()); + ITestStunt value = actual.ToObject(); - Assert.NotNull(actual); Assert.Same(stunt, actual); - Assert.Single(actual!.Behaviors); + Assert.Same(stunt.ToObject(), value); + Assert.Single(stunt.Behaviors); } [Fact] - public void AddAnonymousBehaviorToNonStuntThrows() + public void AddBehaviorToNullStuntThrows() { - var stunt = new object(); - Func method = ToString; + IStunt stunt = null!; - Assert.Throws(() => stunt.AddBehavior( - (m, n) => new MethodReturn(m, "foo", null!), - m => true, - nameof(AddBehavior))); - } - - [Fact] - public void AddBehaviorToNonStuntThrows() - { - var stunt = new object(); - Func method = ToString; - - Assert.Throws(() => stunt.AddBehavior(new TestBehavior())); + Assert.Throws(() => stunt.AddBehavior(new TestBehavior())); } [Fact] public void AddBehaviorFollowsDelegateTarget() { - var stunt = new TestStunt(); - var action = (Action)Delegate.CreateDelegate(typeof(Action), stunt, typeof(TestStunt).GetMethod(nameof(TestStunt.Do))!); + var target = new TestStunt(); + var action = (Action)Delegate.CreateDelegate(typeof(Action), target, typeof(TestStunt).GetMethod(nameof(TestStunt.Do))!); + var stunt = new StuntReference(action); - var actual = action.AddBehavior(new TestBehavior()); + var actual = stunt.AddBehavior(new TestBehavior()); - Assert.Same(action, actual); - Assert.Single(stunt.Behaviors); + Assert.Same(stunt, actual); + Assert.Single(target.Behaviors); } [Fact] public void AddBehaviorOnCombinedDelegateThrows() { - var stunt = new TestStunt(); - var action = (Action)Delegate.CreateDelegate(typeof(Action), stunt, typeof(TestStunt).GetMethod(nameof(TestStunt.Do))!); + var target = new TestStunt(); + var action = (Action)Delegate.CreateDelegate(typeof(Action), target, typeof(TestStunt).GetMethod(nameof(TestStunt.Do))!); Action other = () => { }; var combined = (Action)Delegate.Combine(action, other); - Assert.Throws(() => combined.AddBehavior(new TestBehavior())); - } - - [Fact] - public void AddBehaviorToObject() - { - object stunt = new TestStunt(); - - var actual = stunt.AddBehavior(new TestBehavior()) as IStunt; - - Assert.NotNull(actual); - Assert.Same(stunt, actual); - Assert.Single(actual!.Behaviors); + Assert.Throws(() => new StuntReference(combined)); } [Fact] @@ -145,53 +119,27 @@ public void InsertBehavior() } [Fact] - public void InsertAnonymousBehaviorToObject() + public void InsertBehaviorPreservesTheStuntType() { - object stunt = new TestStunt(); - Func method = ToString; + var stunt = new StuntReference(new TestStunt()); - var actual = stunt.InsertBehavior(0, - (m, n) => new MethodReturn(m, "foo", null!), - m => true, - nameof(InsertAnonymousBehaviorToObject)) as IStunt; + StuntReference actual = stunt.InsertBehavior(0, new TestBehavior()); - Assert.NotNull(actual); Assert.Same(stunt, actual); - Assert.Single(actual!.Behaviors); - } - - [Fact] - public void InsertAnonymousBehaviorToNonStuntThrows() - { - var stunt = new object(); - Func method = ToString; - - Assert.Throws(() => stunt.InsertBehavior(0, - (m, n) => new MethodReturn(m, "foo", null!), - m => true, - nameof(InsertAnonymousBehaviorToNonStuntThrows))); + Assert.Single(stunt.Behaviors); } [Fact] - public void InsertBehaviorToNonStuntThrows() + public void InsertBehaviorToNullStuntThrows() { - var stunt = new object(); - Func method = ToString; + IStunt stunt = null!; - Assert.Throws(() => stunt.InsertBehavior(0, new TestBehavior())); + Assert.Throws(() => stunt.InsertBehavior(0, new TestBehavior())); } [Fact] - public void InsertBehaviorToObject() - { - object stunt = new TestStunt(); - - var actual = stunt.InsertBehavior(0, new TestBehavior()) as IStunt; - - Assert.NotNull(actual); - Assert.Same(stunt, actual); - Assert.Single(actual!.Behaviors); - } + public void StuntOfNonStuntThrows() + => Assert.Throws(() => new StuntReference(new object())); class TestBehavior : IStuntBehavior { @@ -203,7 +151,12 @@ public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next) public override string ToString() => nameof(TestBehavior); } - class TestStunt : IStunt + public interface ITestStunt + { + void Do(); + } + + class TestStunt : ITestStunt, IStunt { public IList Behaviors { get; } = new BehaviorsCollection(); diff --git a/src/Stunts/StuntExtensions.cs b/src/Stunts/StuntExtensions.cs index cc47a6a..be68c29 100644 --- a/src/Stunts/StuntExtensions.cs +++ b/src/Stunts/StuntExtensions.cs @@ -15,41 +15,13 @@ public static class StuntExtensions /// (invocation, next) => invocation.CreateValueReturn() | invocation.CreateExceptionReturn() | next().Invoke(invocation, next) /// invocation => true|false /// Optional friendly name for the behavior. - public static IStunt AddBehavior(this IStunt stunt, ExecuteHandler behavior, AppliesToHandler? appliesTo = null, string? name = null) - { - stunt.Behaviors.Add(new AnonymousBehavior(behavior, appliesTo, name)); - return stunt; - } - - /// - /// Adds a behavior to a stunt. - /// - /// The stunt to add the behavior to. - /// A custom behavior to apply to the stunt. - public static IStunt AddBehavior(this IStunt stunt, IStuntBehavior behavior) - { - stunt.Behaviors.Add(behavior); - return stunt; - } - - /// - /// Adds a behavior to a stunt. - /// - /// The stunt to add the behavior to. - /// (invocation, next) => invocation.CreateValueReturn() | invocation.CreateExceptionReturn() | next().Invoke(invocation, next) - /// invocation => true|false - /// Optional friendly name for the behavior. - //[EditorBrowsable(EditorBrowsableState.Advanced)] public static TStunt AddBehavior(this TStunt stunt, ExecuteHandler behavior, AppliesToHandler? appliesTo = null, string? name = null) + where TStunt : IStunt { - // We can't just add a constraint to the method signature, because - // proxies are typically generated and don't expose the IStunt interface directly. - // A delegate stunt is the bound delegate; its target is the generated instance. - if (AsStunt(stunt) is IStunt target) - target.Behaviors.Add(new AnonymousBehavior(behavior, appliesTo, name)); - else - throw new ArgumentException(nameof(stunt)); + if (stunt == null) + throw new ArgumentNullException(nameof(stunt)); + stunt.Behaviors.Add(new AnonymousBehavior(behavior, appliesTo, name)); return stunt; } @@ -58,14 +30,13 @@ public static TStunt AddBehavior(this TStunt stunt, ExecuteHandler behav /// /// The stunt to add the behavior to. /// A custom behavior to apply to the stunt. - //[EditorBrowsable(EditorBrowsableState.Advanced)] public static TStunt AddBehavior(this TStunt stunt, IStuntBehavior behavior) + where TStunt : IStunt { - if (AsStunt(stunt) is IStunt target) - target.Behaviors.Add(behavior); - else - throw new ArgumentException(nameof(stunt)); + if (stunt == null) + throw new ArgumentNullException(nameof(stunt)); + stunt.Behaviors.Add(behavior); return stunt; } @@ -78,70 +49,31 @@ public static TStunt AddBehavior(this TStunt stunt, IStuntBehavior behav /// (invocation, next) => invocation.CreateValueReturn() | invocation.CreateExceptionReturn() | next().Invoke(invocation, next) /// invocation => true|false /// Optional friendly name for the behavior. - public static IStunt InsertBehavior(this IStunt stunt, int index, ExecuteHandler behavior, AppliesToHandler? appliesTo = null, string? name = null) - { - stunt.Behaviors.Insert(index, new AnonymousBehavior(behavior, appliesTo, name)); - return stunt; - } - - /// - /// Inserts a behavior into the stunt behavior pipeline at the specified - /// index. - /// - /// The stunt to add the behavior to. - /// The index to insert the behavior at. - /// A custom behavior to apply to the stunt. - public static IStunt InsertBehavior(this IStunt stunt, int index, IStuntBehavior behavior) - { - stunt.Behaviors.Insert(index, behavior); - return stunt; - } - - /// - /// Inserts a behavior into the stunt behavior pipeline at the specified - /// index. - /// - /// The stunt to add the behavior to. - /// The index to insert the behavior at. - /// (invocation, next) => invocation.CreateValueReturn() | invocation.CreateExceptionReturn() | next().Invoke(invocation, next) - /// invocation => true|false - /// Optional friendly name for the behavior. - //[EditorBrowsable(EditorBrowsableState.Advanced)] public static TStunt InsertBehavior(this TStunt stunt, int index, ExecuteHandler behavior, AppliesToHandler? appliesTo = null, string? name = null) + where TStunt : IStunt { - if (AsStunt(stunt) is IStunt target) - target.Behaviors.Insert(index, new AnonymousBehavior(behavior, appliesTo, name)); - else - throw new ArgumentException(nameof(stunt)); + if (stunt == null) + throw new ArgumentNullException(nameof(stunt)); + stunt.Behaviors.Insert(index, new AnonymousBehavior(behavior, appliesTo, name)); return stunt; } /// - /// Inserts a behavior into the stunt behavior pipeline at the specified + /// Inserts a behavior into the stunt behavior pipeline at the specified /// index. /// /// The stunt to add the behavior to. /// The index to insert the behavior at. /// A custom behavior to apply to the stunt. - //[EditorBrowsable(EditorBrowsableState.Advanced)] public static TStunt InsertBehavior(this TStunt stunt, int index, IStuntBehavior behavior) + where TStunt : IStunt { - if (AsStunt(stunt) is IStunt target) - target.Behaviors.Insert(index, behavior); - else - throw new ArgumentException(nameof(stunt)); + if (stunt == null) + throw new ArgumentNullException(nameof(stunt)); + stunt.Behaviors.Insert(index, behavior); return stunt; } - - static IStunt? AsStunt(TStunt stunt) - { - if (stunt is IStunt target) - return target; - if (stunt is Delegate delegateStunt && delegateStunt.Target is IStunt delegateTarget) - return delegateTarget; - return null; - } } } From 21f1c5a5116efaf5f3f91c419c2c51a101576d24 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 30 Sep 2026 19:29:37 -0300 Subject: [PATCH 2/6] Move tests and scenarios to the Stunt.For/ToObject pattern The IStunt constraint means behaviors can no longer be configured off a bare stunt instance. Update the affected tests and generator scenarios to create the stunt with Stunt.For, configure behaviors on the returned StuntReference, and materialize it with ToObject(). --- src/Acceptance/Dynamic/Tests.cs | 7 +++-- src/Acceptance/Static/Tests.cs | 7 +++-- src/Stunts.UnitTests/Castle/DelegateTests.cs | 28 ++++++++--------- .../Castle/LanguageFeaturesTests.cs | 26 ++++++++-------- src/Stunts.UnitTests/RefReturns.cs | 7 +++-- src/Stunts.UnitTests/Sample.cs | 1 + .../Scenarios/ClassBaseAbstractType.cs | 7 +++-- .../Scenarios/ClassBaseType.cs | 19 ++++++------ .../Scenarios/ClassBaseTypeCtors.cs | 12 ++++---- .../DefaultInterfaceImplementation.cs | 30 ++++++++++--------- .../Scenarios/InterfaceBase.cs | 15 +++++----- src/Stunts.UnitTests/Scenarios/NestedType.cs | 8 +++-- src/Stunts.UnitTests/Scenarios/RefReturns.cs | 6 ++-- .../Scenarios/RefReturnsOut.cs | 6 ++-- .../Scenarios/RefReturnsValue.cs | 6 ++-- 15 files changed, 102 insertions(+), 83 deletions(-) diff --git a/src/Acceptance/Dynamic/Tests.cs b/src/Acceptance/Dynamic/Tests.cs index c7ff707..aa263a9 100644 --- a/src/Acceptance/Dynamic/Tests.cs +++ b/src/Acceptance/Dynamic/Tests.cs @@ -9,13 +9,14 @@ public class Tests [Fact] public void CanConfigureDefaultValues() { - var calculator = Stunt.Of(); + var stunt = Stunt.For(); + ICalculator calculator = stunt.ToObject(); Assert.IsNotType(StuntFactory.Default); var recorder = new RecordingBehavior(); - calculator.AddBehavior(recorder); - calculator.AddBehavior(new DefaultValueBehavior()); + stunt.AddBehavior(recorder); + stunt.AddBehavior(new DefaultValueBehavior()); Assert.IsAssignableFrom(calculator); diff --git a/src/Acceptance/Static/Tests.cs b/src/Acceptance/Static/Tests.cs index 59e670d..6d8e84e 100644 --- a/src/Acceptance/Static/Tests.cs +++ b/src/Acceptance/Static/Tests.cs @@ -9,12 +9,13 @@ public class Tests [Fact] public void CanConfigureDefaultValues() { - var calculator = Stunt.Of(); + var stunt = Stunt.For(); + ICalculator calculator = stunt.ToObject(); var recorder = new RecordingBehavior(); - calculator.AddBehavior(recorder); - calculator.AddBehavior(new DefaultValueBehavior()); + stunt.AddBehavior(recorder); + stunt.AddBehavior(new DefaultValueBehavior()); Assert.IsAssignableFrom(calculator); diff --git a/src/Stunts.UnitTests/Castle/DelegateTests.cs b/src/Stunts.UnitTests/Castle/DelegateTests.cs index df266b2..b8ad2af 100644 --- a/src/Stunts.UnitTests/Castle/DelegateTests.cs +++ b/src/Stunts.UnitTests/Castle/DelegateTests.cs @@ -27,11 +27,11 @@ public void Run() public void BehaviorHandlesACallWithNoTarget() { var seen = false; - var action = Stunt.Of().AddBehavior((invocation, next) => + Action action = Stunt.For().AddBehavior((invocation, next) => { seen = invocation.MethodBase.Name == "Invoke"; return invocation.CreateReturn(); - }); + }).ToObject(); action(); @@ -42,11 +42,11 @@ public void BehaviorHandlesACallWithNoTarget() public void NoTargetLeavesTheTailEmpty() { bool? hasImplementation = null; - var action = Stunt.Of().AddBehavior((invocation, next) => + Action action = Stunt.For().AddBehavior((invocation, next) => { hasImplementation = invocation.HasImplementation; return next(invocation, next); - }); + }).ToObject(); Assert.Throws(() => action()); Assert.False(hasImplementation); @@ -56,7 +56,7 @@ public void ImplementationIsTheTail() { var called = false; Action implementation = () => called = true; - var action = Stunt.Of(implementation); + Action action = Stunt.Of(implementation); action(); @@ -68,13 +68,13 @@ public void BehaviorWrapsTheImplementation() { var order = new List(); Action implementation = () => order.Add("impl"); - var action = Stunt.Of(implementation).AddBehavior((invocation, next) => + Action action = Stunt.For(implementation).AddBehavior((invocation, next) => { order.Add("before"); var result = next(invocation, next); order.Add("after"); return result; - }); + }).ToObject(); action(); @@ -85,11 +85,11 @@ public void BehaviorCanReplaceAnArgument() { var seen = 0; Action implementation = value => seen = value; - var action = Stunt.Of>(implementation).AddBehavior((invocation, next) => + Action action = Stunt.For>(implementation).AddBehavior((invocation, next) => { invocation.Arguments.SetValue("obj", 41); return next(invocation, next); - }); + }).ToObject(); action(1); @@ -104,11 +104,11 @@ public void RefAndReturnAreWrittenBack() value = result; return result; }; - var action = Stunt.Of(implementation).AddBehavior((invocation, next) => + RefImpl action = Stunt.For(implementation).AddBehavior((invocation, next) => { invocation.Arguments.SetValue("value", 4); return next(invocation, next); - }); + }).ToObject(); var value = 1; var returned = action(ref value); @@ -130,7 +130,7 @@ public void ParameterNamedMethodForwards() { var seen = 0; Named implementation = method => seen = method; - var action = Stunt.Of(implementation); + Named action = Stunt.Of(implementation); action(7); @@ -141,7 +141,7 @@ public void ParametersThatHideFieldsForward() { var seen = ""; HidesFields target = (left, right) => seen = left + ":" + right; - var action = Stunt.Of(target); + HidesFields action = Stunt.Of(target); action(2, 9); @@ -160,7 +160,7 @@ public void Run() { var called = false; Hidden implementation = () => called = true; - var action = Stunt.Of(implementation); + Hidden action = Stunt.Of(implementation); action(); diff --git a/src/Stunts.UnitTests/Castle/LanguageFeaturesTests.cs b/src/Stunts.UnitTests/Castle/LanguageFeaturesTests.cs index 1724b32..9676049 100644 --- a/src/Stunts.UnitTests/Castle/LanguageFeaturesTests.cs +++ b/src/Stunts.UnitTests/Castle/LanguageFeaturesTests.cs @@ -18,41 +18,43 @@ public void Run() public void CovariantReturnUsesTheDerivedSignature() { - var stunt = Stunt.Of(); + var stunt = Stunt.For(); + StringReturn value = stunt.ToObject(); - Assert.Equal("derived", stunt.Value()); + Assert.Equal("derived", value.Value()); stunt.AddBehavior((invocation, next) => invocation.CreateValueReturn("proxy")); - Assert.Equal("proxy", stunt.Value()); + Assert.Equal("proxy", value.Value()); } public void PositionalRecordConstructorAndVirtualMethod() { - var stunt = Stunt.Of("Ada"); + var stunt = Stunt.For("Ada"); + Person person = stunt.ToObject(); - Assert.Equal("Ada", stunt.Name); - Assert.Equal("Hello Ada", stunt.Greet()); + Assert.Equal("Ada", person.Name); + Assert.Equal("Hello Ada", person.Greet()); stunt.AddBehavior((invocation, next) => invocation.MethodBase.Name == nameof(Person.Greet) ? invocation.CreateValueReturn("hi") : next(invocation, next)); - Assert.Equal("hi", stunt.Greet()); - Assert.Equal("Ada", stunt.Name); + Assert.Equal("hi", person.Greet()); + Assert.Equal("Ada", person.Name); - var copy = stunt with { Name = "Bea" }; + var copy = person with { Name = "Bea" }; Assert.Equal("Bea", copy.Name); - Assert.IsType(stunt.GetType(), copy); + Assert.IsType(person.GetType(), copy); // The synthesized record copy copies the pipeline field. Assert.Equal("hi", copy.Greet()); } public void EmptyRecord() { - var stunt = Stunt.Of(); + Empty stunt = Stunt.Of(); Assert.IsAssignableFrom(stunt); Assert.IsAssignableFrom(stunt); @@ -60,7 +62,7 @@ public void EmptyRecord() public void RecordCanTakeAnAdditionalInterface() { - var stunt = Stunt.Of("Ada"); + Person stunt = Stunt.Of("Ada"); Assert.IsAssignableFrom(stunt); Assert.IsAssignableFrom(stunt); diff --git a/src/Stunts.UnitTests/RefReturns.cs b/src/Stunts.UnitTests/RefReturns.cs index f6a53f4..63bab66 100644 --- a/src/Stunts.UnitTests/RefReturns.cs +++ b/src/Stunts.UnitTests/RefReturns.cs @@ -9,15 +9,16 @@ public class RefReturns [Fact] public void CanReturnRef() { - INumbers stunt = new NumbersStunt(); - + var stunt = new NumbersStunt(); Ref value = 25; stunt.AddBehavior((invocation, next) => invocation.CreateValueReturn(value, invocation.Arguments)); + INumbers numbers = stunt; + var index = 0; - ref int v = ref stunt.NumberAt(ref index, out var count); + ref int v = ref numbers.NumberAt(ref index, out var count); v = 42; diff --git a/src/Stunts.UnitTests/Sample.cs b/src/Stunts.UnitTests/Sample.cs index 8a5812c..08fda67 100644 --- a/src/Stunts.UnitTests/Sample.cs +++ b/src/Stunts.UnitTests/Sample.cs @@ -1,4 +1,5 @@ using System; +using Sample; using Stunts.Sample; using Xunit; diff --git a/src/Stunts.UnitTests/Scenarios/ClassBaseAbstractType.cs b/src/Stunts.UnitTests/Scenarios/ClassBaseAbstractType.cs index 13e7e0f..52710a0 100644 --- a/src/Stunts.UnitTests/Scenarios/ClassBaseAbstractType.cs +++ b/src/Stunts.UnitTests/Scenarios/ClassBaseAbstractType.cs @@ -10,10 +10,11 @@ public class Test : IRunnable public void Run() { var stunt = Stunt.Of(); + CalculatorBase calculator = stunt; - Assert.Throws(() => stunt.Mode = CalculatorMode.Scientific); - Assert.Throws(() => stunt.Mode); - Assert.Throws(() => stunt.TurnOn()); + Assert.Throws(() => calculator.Mode = CalculatorMode.Scientific); + Assert.Throws(() => calculator.Mode); + Assert.Throws(() => calculator.TurnOn()); } } } diff --git a/src/Stunts.UnitTests/Scenarios/ClassBaseType.cs b/src/Stunts.UnitTests/Scenarios/ClassBaseType.cs index e0dc18f..40effd4 100644 --- a/src/Stunts.UnitTests/Scenarios/ClassBaseType.cs +++ b/src/Stunts.UnitTests/Scenarios/ClassBaseType.cs @@ -40,28 +40,29 @@ public class Test : IRunnable public void Run() { var stunt = Stunt.Of(); + BaseType instance = stunt; - stunt.Platform = PlatformID.MacOSX; + instance.Platform = PlatformID.MacOSX; - Assert.Equal(PlatformID.MacOSX, stunt.Platform); + Assert.Equal(PlatformID.MacOSX, instance.Platform); - Assert.False(stunt.IsOn); + Assert.False(instance.IsOn); var on = false; - stunt.TurnedOn += (_, _) => on = true; - stunt.TurnOn(); - Assert.True(stunt.IsOn); + instance.TurnedOn += (_, _) => on = true; + instance.TurnOn(); + Assert.True(instance.IsOn); Assert.True(on); var x = 5; var y = 10; var mem = 42; - Assert.True(stunt.TryAdd(x, y, ref mem, out var z)); + Assert.True(instance.TryAdd(x, y, ref mem, out var z)); Assert.Equal(15, z); - stunt[0, "foo"] = "bar"; + instance[0, "foo"] = "bar"; - Assert.Equal("bar", stunt[0, "foo"]); + Assert.Equal("bar", instance[0, "foo"]); } } } diff --git a/src/Stunts.UnitTests/Scenarios/ClassBaseTypeCtors.cs b/src/Stunts.UnitTests/Scenarios/ClassBaseTypeCtors.cs index b298c4d..2c36904 100644 --- a/src/Stunts.UnitTests/Scenarios/ClassBaseTypeCtors.cs +++ b/src/Stunts.UnitTests/Scenarios/ClassBaseTypeCtors.cs @@ -20,15 +20,15 @@ public class Test : IRunnable { public void Run() { - var stunt = Stunt.Of("Foo"); + BaseTypeCtor instance = Stunt.Of("Foo"); - Assert.Equal("Foo", stunt.Name); - Assert.True(stunt.Enabled); + Assert.Equal("Foo", instance.Name); + Assert.True(instance.Enabled); - stunt = Stunt.Of("Foo", false); + instance = Stunt.Of("Foo", false); - Assert.Equal("Foo", stunt.Name); - Assert.False(stunt.Enabled); + Assert.Equal("Foo", instance.Name); + Assert.False(instance.Enabled); } } } \ No newline at end of file diff --git a/src/Stunts.UnitTests/Scenarios/DefaultInterfaceImplementation.cs b/src/Stunts.UnitTests/Scenarios/DefaultInterfaceImplementation.cs index 10da2ee..3b9ebe5 100644 --- a/src/Stunts.UnitTests/Scenarios/DefaultInterfaceImplementation.cs +++ b/src/Stunts.UnitTests/Scenarios/DefaultInterfaceImplementation.cs @@ -46,7 +46,7 @@ public void Run() public void DefaultsRunWhenNotIntercepted() { - var stunt = Stunt.Of(); + IDefault stunt = Stunt.Of(); Assert.Equal(5, stunt.Value); Assert.Equal("Hello Ada", stunt.Greet("Ada")); @@ -57,10 +57,10 @@ public void DefaultsRunWhenNotIntercepted() public void DefaultsCanBeReplaced() { - var stunt = Stunt.Of().AddBehavior((invocation, next) => + IDefault stunt = Stunt.For().AddBehavior((invocation, next) => invocation.MethodBase.Name == "get_Value" ? invocation.CreateValueReturn(42) - : next(invocation, next)); + : next(invocation, next)).ToObject(); Assert.Equal(42, stunt.Value); Assert.Equal("Hello Ada", stunt.Greet("Ada")); @@ -68,13 +68,13 @@ public void DefaultsCanBeReplaced() public void DefaultsCanBeProceededTo() { - var stunt = Stunt.Of().AddBehavior((invocation, next) => + IDefault stunt = Stunt.For().AddBehavior((invocation, next) => { var result = next(invocation, next); return invocation.MethodBase.Name == nameof(IDefault.Greet) ? invocation.CreateValueReturn(result.ReturnValue + "!") : result; - }); + }).ToObject(); Assert.Equal("Hello Ada!", stunt.Greet("Ada")); Assert.Equal(5, stunt.Value); @@ -83,12 +83,12 @@ public void DefaultsCanBeProceededTo() public void DefaultEventIsIntercepted() { var added = 0; - var stunt = Stunt.Of().AddBehavior((invocation, next) => + IDefault stunt = Stunt.For().AddBehavior((invocation, next) => { if (invocation.MethodBase.Name == "add_Changed") added++; return next(invocation, next); - }); + }).ToObject(); stunt.Changed += (sender, args) => { }; @@ -97,7 +97,7 @@ public void DefaultEventIsIntercepted() public void DerivedInterfaceDefaultIsMostSpecific() { - var stunt = Stunt.Of(); + IDerived stunt = Stunt.Of(); Assert.Equal(6, stunt.Value); Assert.Equal("Hello Ada", stunt.Greet("Ada")); @@ -105,27 +105,29 @@ public void DerivedInterfaceDefaultIsMostSpecific() public void GenericInterfaceDefault() { - var stunt = Stunt.Of>(); + var stunt = Stunt.For>(); + IEcho echo = stunt.ToObject(); - Assert.Equal("Ada", stunt.Echo("Ada")); + Assert.Equal("Ada", echo.Echo("Ada")); stunt.AddBehavior((invocation, next) => invocation.CreateValueReturn("Bea")); - Assert.Equal("Bea", stunt.Echo("Ada")); + Assert.Equal("Bea", echo.Echo("Ada")); } public void ClassWithAdditionalInterfaceDefault() { - var stunt = Stunt.Of(); + var stunt = Stunt.For(); + Impl impl = stunt.ToObject(); - Assert.Equal(5, ((IDefault)stunt).Value); + Assert.Equal(5, ((IDefault)impl).Value); stunt.AddBehavior((invocation, next) => invocation.MethodBase.Name == "get_Value" ? invocation.CreateValueReturn(42) : next(invocation, next)); - Assert.Equal(42, ((IDefault)stunt).Value); + Assert.Equal(42, ((IDefault)impl).Value); } public void DefaultImplementationClass() diff --git a/src/Stunts.UnitTests/Scenarios/InterfaceBase.cs b/src/Stunts.UnitTests/Scenarios/InterfaceBase.cs index 194ea88..6395fe1 100644 --- a/src/Stunts.UnitTests/Scenarios/InterfaceBase.cs +++ b/src/Stunts.UnitTests/Scenarios/InterfaceBase.cs @@ -18,21 +18,22 @@ public class Test : IRunnable public void Run() { using var ambient = BehaviorPipelineFactory.UseAmbient(new RecordingBehaviorPipelineFactory()); - var stunt = Stunt.Of(); + var stunt = Stunt.For(); + IBasicInterface basic = stunt.ToObject(); - Assert.NotNull(stunt); - Assert.IsAssignableFrom(stunt); + Assert.NotNull(basic); + Assert.IsAssignableFrom(basic); // Recorder tracks call to constructor. - Assert.Single(((IStunt)stunt).Behaviors); - Assert.Single(((RecordingBehavior)((IStunt)stunt).Behaviors[0]).Invocations); + Assert.Single(stunt.Behaviors); + Assert.Single(((RecordingBehavior)stunt.Behaviors[0]).Invocations); // If no returning behavior is configured, invoking it throws. - Assert.Throws(() => stunt.Run()); + Assert.Throws(() => basic.Run()); // When we add at least one matching behavior, invocations succeed. stunt.AddBehavior(new DefaultValueBehavior()); - stunt.Run(); + basic.Run(); } class RecordingBehaviorPipelineFactory : IBehaviorPipelineFactory diff --git a/src/Stunts.UnitTests/Scenarios/NestedType.cs b/src/Stunts.UnitTests/Scenarios/NestedType.cs index ff77bfa..944b301 100644 --- a/src/Stunts.UnitTests/Scenarios/NestedType.cs +++ b/src/Stunts.UnitTests/Scenarios/NestedType.cs @@ -10,10 +10,12 @@ public class Test : IRunnable { public void Run() { - var stunt = Stunt.Of() - .AddBehavior(new DefaultValueBehavior()); + var stunt = Stunt.For() + .AddBehavior(new DefaultValueBehavior()).ToObject(); - stunt.Do(); + IFoo foo = stunt; + + foo.Do(); } public interface IFoo diff --git a/src/Stunts.UnitTests/Scenarios/RefReturns.cs b/src/Stunts.UnitTests/Scenarios/RefReturns.cs index 17a6858..4b1a6ad 100644 --- a/src/Stunts.UnitTests/Scenarios/RefReturns.cs +++ b/src/Stunts.UnitTests/Scenarios/RefReturns.cs @@ -16,10 +16,12 @@ public class Test : IRunnable { public void Run() { - var stunt = Stunt.Of(); + var stunt = Stunt.For(); stunt.AddBehavior(new DefaultValueBehavior()); - ref int value = ref stunt.Get(); + IMemory memory = stunt.ToObject(); + + ref int value = ref memory.Get(); Assert.Equal(0, value); } } diff --git a/src/Stunts.UnitTests/Scenarios/RefReturnsOut.cs b/src/Stunts.UnitTests/Scenarios/RefReturnsOut.cs index 7398c36..0c000ee 100644 --- a/src/Stunts.UnitTests/Scenarios/RefReturnsOut.cs +++ b/src/Stunts.UnitTests/Scenarios/RefReturnsOut.cs @@ -16,11 +16,13 @@ public class Test : IRunnable { public void Run() { - var stunt = Stunt.Of(); + var stunt = Stunt.For(); stunt.AddBehavior(new DefaultValueBehavior()); + IMemory memory = stunt.ToObject(); + var name = "foo"; - ref int value = ref stunt.Get(ref name, out var _); + ref int value = ref memory.Get(ref name, out var _); Assert.Equal(0, value); } diff --git a/src/Stunts.UnitTests/Scenarios/RefReturnsValue.cs b/src/Stunts.UnitTests/Scenarios/RefReturnsValue.cs index b59d4bd..6ce3486 100644 --- a/src/Stunts.UnitTests/Scenarios/RefReturnsValue.cs +++ b/src/Stunts.UnitTests/Scenarios/RefReturnsValue.cs @@ -16,12 +16,14 @@ public class Test : IRunnable { public void Run() { - var stunt = Stunt.Of(); + var stunt = Stunt.For(); Ref original = 12; stunt.AddBehavior((invocation, next) => invocation.CreateValueReturn(original)); - ref int value = ref stunt.Get(); + IMemory memory = stunt.ToObject(); + + ref int value = ref memory.Get(); value = 42; // Original value changes too :) From c29dc88e85626a620661e874802d3d06b1a2b610 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 30 Sep 2026 19:29:41 -0300 Subject: [PATCH 3/6] Document behavior configuration through Stunt.For The API overview and package description still showed behaviors being added directly on the stunt. Show Stunt.For with ToObject(), and mention Stunt.Get for configuring an already created stunt. --- docs/api/index.md | 20 +++++++++++++++---- src/Stunts.Package/Stunts.Package.msbuildproj | 6 ++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 7e7602f..47fb694 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -5,17 +5,29 @@ Main entry point API is `Stunt.Of`, which creates an object that implements ` ```csharp ICalculator calculator = Stunt.Of(); -// Extension methods to easily add behaviors without having -// to cast to IStunt -calculator.AddBehavior((invocation, next) => ...); +Console.WriteLine(calculator.Add(2, 5)); +``` + +Behaviors are configured through `IStunt`, which every stunt implements. `Stunt.For` +creates the stunt and returns a `StuntReference` that exposes both the behaviors and +the stunt itself (via `ToObject()`, or an implicit conversion for non-interface types): + +```csharp +var stunt = Stunt.For(); +ICalculator calculator = stunt.ToObject(); + +stunt.AddBehavior((invocation, next) => ...); Console.WriteLine(calculator.Add(2, 5)); ``` +`Stunt.Get(calculator)` returns the same `StuntReference` for an already created stunt, +so behaviors can be added after the fact. + There are overloads for implementing additional types, as well as passing constructor arguments if the base type `T` (which must be the first in the list, like in regular C# type declarations) is a class that provides a constructor with matching parameters: -`Stunt.Of(arg1, ... argn)` +`Stunt.Of(arg1, ... argn)` and `Stunt.For(arg1, ... argn)` For anonymous behaviors, the delegate/lambda based overloads are typically sufficient. For more advanced or reusable behaviors, you can implement `IStuntBehavior` instead. diff --git a/src/Stunts.Package/Stunts.Package.msbuildproj b/src/Stunts.Package/Stunts.Package.msbuildproj index 15685cc..5473e2f 100644 --- a/src/Stunts.Package/Stunts.Package.msbuildproj +++ b/src/Stunts.Package/Stunts.Package.msbuildproj @@ -10,8 +10,10 @@ and runs everywhere, even where run-time code generation (Reflection.Emit) is fo limitted, like physical iOS devices and game consoles, through compile-time code generation. The proxy behavior is configured in code using what we call a behavior pipeline. - IMyService service = Stunt.Of<IMyService>(); -service.AddBehavior(...); + ICalculator calculator = Stunt + .For<ICalculator>() + .AddBehavior((invocation, next) => ..., appliesTo: invocation => ...) + .ToObject(); dotnet proxy runtime dynamic From df46cc28a9010fbd4f784954707e4708b4481aaf Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 30 Sep 2026 19:29:45 -0300 Subject: [PATCH 4/6] Re-evaluate Castle feature support notes Custom attribute replication and optional parameter defaults are now generated and covered by AttributeTests, so drop them from the gap list and record them as supported. Narrow the remaining metadata and serialization entries to what is genuinely Castle-specific, and restate the interface limitation as the inability to tell identical interface slots apart. --- src/Stunts.UnitTests/Castle/Unsupported.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Stunts.UnitTests/Castle/Unsupported.md b/src/Stunts.UnitTests/Castle/Unsupported.md index 7f48c7d..10c0f41 100644 --- a/src/Stunts.UnitTests/Castle/Unsupported.md +++ b/src/Stunts.UnitTests/Castle/Unsupported.md @@ -31,6 +31,7 @@ The tests next to this file are the Castle scenarios the generator can carry. Th | Covariant returns | `LanguageFeaturesTests` | | Records (positional, empty, record plus an extra interface; `with` keeps the stunt type) | `LanguageFeaturesTests` | | `ref readonly`, `Span` and other `ref struct` arguments and returns, pointer parameters | `Scenarios/SignatureRefs` | +| Custom attributes on generated types, members, parameters, and returns; optional parameter defaults (including reflection invocation) | `AttributeTests` | Behaviors added with `AddBehavior` after `Stunt.Of` do not see virtual calls made from a base constructor. Castle passes interceptors into proxy creation, so they do. `BehaviorPipelineFactory` is the matching extension point, and `ClassProxyTests` uses it. @@ -46,24 +47,28 @@ Castle's `IProxyGenerationHook` omits members from the proxy type. Stunts overri ### Metadata Castle copies onto the proxy -The scaffold copies signatures, names, and generic constraints. It does not copy: +The generator copies C#-expressible metadata, including custom attributes and optional +parameter default values (covered by `AttributeTests`). Remaining metadata gaps are: -- custom attributes on methods, properties, parameters, or the type (`[Serializable]`, `[Guid]`, parameter attributes) -- metadata-only custom modifiers (`modopt` / `modreq`) that have no C# spelling. `in`, `ref readonly`, and function-pointer calling conventions are emitted as those keywords, and the compiler writes the matching modifier -- optional-parameter default values (callers compiled against the original type still get the compiler default; reflection on the proxy method does not) +- metadata-only custom modifiers (`modopt` / `modreq`) with no C# spelling - extra attributes supplied at generation time (`ProxyGenerationOptions.AdditionalAttributes`) ### Serialization -Castle's proxy serialization (BinaryFormatter, `ISerializable`, restoring interceptors, mixins, selector, and generation options, including graphs with delegates back to the proxy) is a generator feature. Compile-time stunts are ordinary classes with none of that infrastructure. XML serialization of a proxy is the same kind of gap: the generated type does not replicate the attributes and constructor shape `XmlSerializer` expects. +Castle-specific proxy serialization (`ISerializable`, restoring interceptors, mixins, +selector, and generation options, including graphs with delegates back to the proxy) is +not implemented by compile-time stunts. Copying `[Serializable]` and other attributes +does not provide that proxy-serialization infrastructure. ### Delegates as additional type arguments `Stunt.Of()` returns a delegate bound to a generated `Invoke`, and `Stunt.Of(implementation)` forwards `next` to that delegate. A delegate combined with a class or another interface is `ST010`. Castle can also mix a delegate `Invoke` onto some other proxy (`AsDelegate` is the missing cast). -### Same signature on two interfaces +### Cannot distinguish identical interface slots -One public method implements both. Both calls are intercepted, and a behavior cannot tell which interface was used. Castle emits explicit implementations so `IInvocation.Method` differs. +Identical signatures from two interfaces share one generated implementation. Both calls +are intercepted, but a behavior cannot tell which interface slot was called. Castle can +emit explicit implementations so `IInvocation.Method` differs. ### Fixed additional-interface arity From deba0772d1f661236f423f245829f923328f37a0 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 30 Sep 2026 19:29:48 -0300 Subject: [PATCH 5/6] Drop unused ThisAssembly reference from Stunts.CompiledProxy Nothing in the project consumes the generated ThisAssembly constants. --- src/Stunts.CompiledProxy/Stunts.CompiledProxy.csproj | 2 +- src/Stunts.CompiledProxy/Stunts.CompiledProxy.targets | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Stunts.CompiledProxy/Stunts.CompiledProxy.csproj b/src/Stunts.CompiledProxy/Stunts.CompiledProxy.csproj index cca6776..acc4898 100644 --- a/src/Stunts.CompiledProxy/Stunts.CompiledProxy.csproj +++ b/src/Stunts.CompiledProxy/Stunts.CompiledProxy.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 diff --git a/src/Stunts.CompiledProxy/Stunts.CompiledProxy.targets b/src/Stunts.CompiledProxy/Stunts.CompiledProxy.targets index bbb1b4b..c58327d 100644 --- a/src/Stunts.CompiledProxy/Stunts.CompiledProxy.targets +++ b/src/Stunts.CompiledProxy/Stunts.CompiledProxy.targets @@ -18,7 +18,6 @@ - From 71b9d389220259264ec10d2db8d54e4a2f44412c Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 1 Oct 2026 13:57:40 -0300 Subject: [PATCH 6/6] Fix VB dynamic acceptance behavior setup --- src/Acceptance/Dynamic.Basic/Test.vb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Acceptance/Dynamic.Basic/Test.vb b/src/Acceptance/Dynamic.Basic/Test.vb index fd8ff67..9aff6d4 100644 --- a/src/Acceptance/Dynamic.Basic/Test.vb +++ b/src/Acceptance/Dynamic.Basic/Test.vb @@ -8,13 +8,14 @@ Namespace Sample Public Sub CanConfigureDefaultValues() - Dim calculator = Stunt.[Of](Of ICalculator, IDisposable)() + Dim reference = Stunt.[For](Of ICalculator, IDisposable)() + Dim calculator As ICalculator = reference.ToObject() Assert.IsNotType(Of CompiledStuntFactory)(StuntFactory.[Default]) Dim recorder = New RecordingBehavior() - calculator.AddBehavior(recorder) - calculator.AddBehavior(New DefaultValueBehavior()) + reference.AddBehavior(recorder) + reference.AddBehavior(New DefaultValueBehavior()) Assert.IsAssignableFrom(Of IDisposable)(calculator)