Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ Every factory overload (including the `For` and `Build` ones) must carry `[Stunt
generator keys off that attribute on the invoked method (instance or static) and uses the call site's
generic type arguments to decide which stunt types to generate.

### Constructed types containing method type parameters

`Stunt.Of<IDictionary<T0, T1>>()` inside a generic method emits a generic proxy template.
`GenericStuntTemplate` collects free parameters (including containing types and constraint
dependencies), assigns collision-free canonical names, and fingerprints the target shapes
and constraints so equivalent factories share a template while different constraints stay distinct.
The generated `StuntTemplateAttribute`-marked static method records target shapes through its
parameter types. It is metadata only and must remain excluded from pipeline rewriting.

`CompiledStuntFactory` first looks up the existing closed-type name, then unifies requested
targets with cached template descriptors and closes a matching template. Matching preserves
repeated parameters, nested arguments, array shapes, and the exact additional-interface set.
The cache is weakly keyed by assembly. Constraint failures are retained as the inner exception
when no valid template is found.

A composed generic wrapper needs no `[StuntGenerator]`. If it is annotated in source,
`GenericFactory.UsesTemplate` makes the receiver and analyzers defer to the constructed-type
call in its body instead of interpreting `Create<string, int>()` as proxies for `string` and `int`.
Factories that also call `Stunt.Of<T>()` retain call-site generation for `T`, even when another
call in the same body emits a template.

Generic proxies rebind `MethodBase.GetCurrentMethod()` to their constructed declaring type
before closing generic methods, so invocation metadata and argument types are concrete.
Default interface implementation helpers are generic too.

`StuntGeneratorTests.CreatesConstructedGenericStuntsFromGenericMethod` covers annotated and
unannotated wrappers, including runtime type combinations absent from factory call sites.
`Scenarios/OpenGenericTemplates.cs` covers canonical reuse, constraints, nested and repeated
arguments, classes, delegates, builders, default interface members, and closed-proxy precedence.

## `StuntBuilder`

`StuntBuilder` (also in `Stunt.cs`/`Stunt.vb`) implements `IStunt`, so the `AddBehavior`/
Expand Down Expand Up @@ -108,3 +138,85 @@ The `Stunts` and `Stunts.CodeAnalysis` assemblies are consumed as analyzers by o
in the solution, so a full `dotnet build` may fail with file locks (`CS2012`) when an IDE has
the solution open. Close the IDE, and if needed build projects one at a time with
`/p:UseSharedCompilation=false`. Tests run with `dnx --yes retest`.

## Generator execution

`DependencyResolver` accepts simple-name assembly probes (their `Version` is null),
and keeps registered paths in an immutable snapshot for concurrent compiler runs.
Assembly-load errors propagate to Roslyn instead of invoking `Debug.Fail`, which
can terminate the compiler process during diagnostic-resource probing.

Generator caches belong to a single execution: reuse semantic models per syntax
tree and inspect each factory's original definition once. Event rewriting binds
only declarations containing events. Interface scaffolding considers methods,
properties, and events, not nested type declarations.

Deduplicate stunts and default-interface helpers by namespace-qualified names.
Roslyn compares source hint names case-insensitively; qualify them and disambiguate
case-only collisions without changing generated type names. Sealed targets report
`ST003` while other candidates continue generating.

### Signature compatibility

`MemberScaffold` preserves optional defaults, escaped identifiers, indexer metadata names,
and inherited override constraints. External `protected internal` overrides become
`protected`; constructor signatures exposing protected nested types remain protected.
Attribute replication must not introduce inaccessible type references or invalid constants.

Generated pipeline access is qualified with `this`, and generated locals/lambda parameters
avoid names from the intercepted signature. Signatures longer than the `MethodInvocation.Create`
overload limit use an `ArgumentCollection` instead. Ref-struct and pointer accessors use the same
holder path as methods. Ref-struct holders are invalidated in `finally`, including constructor
interception; constructor output arguments already initialized by the base call must be preserved.
Mixed holder and ordinary `ref`/`out` signatures copy pipeline outputs back to the caller.

`InterfaceImplementation` is shared by the analyzer and generator. `ST012` rejects abstract
interface members inaccessible to the consuming assembly, including internal setters:
omitting the setter produces `CS0535`, while explicitly implementing it produces `CS0122`.
Same-assembly and friend-assembly implementations remain supported, as do classes with an
existing concrete implementation. `ST012_InaccessibleInterfaceMember` tests the actual C#
compiler restriction using separate metadata assemblies.

`RuntimeSignature` similarly reports `ST013` for interceptable signatures using
`TypedReference`, `ArgIterator`, or `RuntimeArgumentHandle`, which cannot be represented by
the pipeline's boxed arguments or generic holders. Invalid candidates do not prevent unrelated
valid stunts from being generated.

## Comparison package acceptance coverage

`src/Acceptance/GenerateReferenceStunts.cs` is a .NET file-based app containing the stable
package snapshot used by the `Static` acceptance project (prerelease references and the cloned
`efcore` and `iotedge` repositories are intentionally excluded). Run
`dotnet run --file GenerateReferenceStunts.cs` from `src/Acceptance` to regenerate
`src/Acceptance/Static/Stunts.cs`. The generator always resolves that output relative to its own
source file, loads the runtime assemblies from the comparison packages plus
`Microsoft.AspNetCore.App`, and writes a `Stunt.Of<T>()` call for every public interface and
non-sealed class that is compiler-visible and proxyable, is not marked as a preview feature,
and has no public preview members. Inaccessible abstract members, unavailable constructors,
static-abstract interfaces, special CLR signatures, and obsolete-as-error APIs are excluded.
Open generic types are emitted inside helper methods with their generic constraints preserved.

The script preserves its compilation context and reads public metadata names from compile
assets, filtering runtime-only types absent from reference assemblies. Match full metadata
names, not assembly names: runtime core types and their reference definitions live in different
assemblies. If changing file-based app properties leaves stale dependency metadata, rebuild
the script explicitly before running it.

Reflection type names preserve every declaring-type separator independently of generic arity.
Distribute the requested type's concrete generic arguments across its declaring-type chain;
`DeclaringType` itself reopens constructed generics. Map free parameters by identity and
preserve array ranks in C# order, including inside generic arguments and constraints.
`AcceptanceTypeNameTests` compiles the script's actual formatting helpers without executing
its package-loading entry point.

Keep the script directives and `src/Acceptance/Static/Stunts.props` synchronized when the
comparison projects change. The props file gives the acceptance compilations matching metadata
references; comparison-only references expose compile, runtime, and native assets, but not
build targets or analyzers. Runtime dependencies are required because xUnit reflects over the
generated types during discovery. A zero-test run is not successful acceptance validation:
verify that tests are actually discovered and executed.

Build and package the root solution before running `src/Acceptance/Acceptance.slnx`.
The root solution lists `Static.csproj` as a solution item rather than a project:
acceptance consumes the produced packages, whose NuGet cache entries are cleared by
the package build, so including it in that build creates a bootstrap/cache-order cycle.
25 changes: 25 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ public static T Of<T, T1>(params object[] constructorArgs) => Create<T>(construc

As you can see, the Stunts API itself uses the same extensibility mechanism that your own custom factory methods can use.

The attribute normally treats the generic arguments at the call site as the stunt's base type and
additional interfaces. Factories that construct generic types inside their bodies also work:

```csharp
static IDictionary<TKey, TValue> Create<TKey, TValue>()
=> Stunt.Of<IDictionary<TKey, TValue>>();

IDictionary<string, int> dictionary = Create<string, int>();
```

This wrapper needs no `[StuntGenerator]`: the call in its body generates a generic proxy template.
At runtime, the compiled factory closes that template with the requested types, without tracing
callers. Templates preserve constraints and support nested type arguments, classes, delegates, and
additional interfaces. An existing closed proxy takes precedence over a matching template.

### Compiled vs Dynamic Stunts

By default, Stunts generates proxies at compile-time (powered by Roslyn source generators). Whenever compile-time stunts are
Expand All @@ -156,6 +171,16 @@ not supported (or unwanted), install the `Stunts.DynamicProxy` package, which sw

The package sets `EnableCompileTimeStunts=false` for you. Projects that can't use compile-time stunts and don't reference `Stunts.DynamicProxy` get a build warning (`ST011`).

Compile-time stunts support optional parameters, inherited generic constraints, long signatures,
and `ref`/`out` arguments alongside spans and other ref structs. Ref-struct and pointer signatures
require unsafe blocks to be enabled.

Unsupported targets produce diagnostics at the factory call: `ST012` identifies inaccessible
abstract interface members (such as an internal setter from another assembly, which C# cannot
implement), while `ST013` identifies signatures using `TypedReference`, `ArgIterator`, or
`RuntimeArgumentHandle`, which the behavior pipeline cannot represent. Internal interface
members remain supported when accessible through the same assembly or `InternalsVisibleTo`.

<!-- #manual -->
> NOTE: even though generated proxies are the main usage for Stunts, the API was designed so that you can also consume the behavior pipeline easily from hand-coded proxies too.
<!-- #manual -->
Expand Down
49 changes: 49 additions & 0 deletions src/Stunts.CodeAnalysis/GenericFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Operations;

namespace Stunts.CodeAnalysis
{
static class GenericFactory
{
public static bool UsesTemplate(IMethodSymbol method, Compilation compilation, INamedTypeSymbol attribute, CancellationToken cancellationToken)
{
foreach (var reference in method.OriginalDefinition.DeclaringSyntaxReferences)
{
var syntax = reference.GetSyntax(cancellationToken);
var model = compilation.GetSemanticModel(syntax.SyntaxTree);
if (model.GetOperation(syntax, cancellationToken) is not IOperation operation)
continue;
var usesTemplate = false;
var needsCallerTypes = false;
Inspect(operation, attribute, ref usesTemplate, ref needsCallerTypes);
if (usesTemplate && !needsCallerTypes)
return true;
}

return false;
}

static void Inspect(IOperation operation, INamedTypeSymbol attribute, ref bool usesTemplate, ref bool needsCallerTypes)
{
if (operation is IInvocationOperation invocation &&
invocation.TargetMethod.GetAttributes().Any(value => SymbolEqualityComparer.Default.Equals(value.AttributeClass, attribute)))
{
usesTemplate |= invocation.TargetMethod.TypeArguments.OfType<INamedTypeSymbol>().Any(ContainsParameter);
needsCallerTypes |= invocation.TargetMethod.TypeArguments.Any(type => type is ITypeParameterSymbol);
}
foreach (var child in operation.ChildOperations)
Inspect(child, attribute, ref usesTemplate, ref needsCallerTypes);
}

static bool ContainsParameter(ITypeSymbol type) => type switch
{
ITypeParameterSymbol => true,
IArrayTypeSymbol array => ContainsParameter(array.ElementType),
INamedTypeSymbol named => named.TypeArguments.Any(ContainsParameter) ||
named.ContainingType != null && ContainsParameter(named.ContainingType),
_ => false,
};
}
}
42 changes: 42 additions & 0 deletions src/Stunts.CodeAnalysis/InterfaceImplementation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Linq;
using Microsoft.CodeAnalysis;

namespace Stunts.CodeAnalysis;

static class InterfaceImplementation
{
public static ISymbol? InaccessibleMember(INamedTypeSymbol type, Compilation compilation)
{
var interfaces = type.TypeKind == TypeKind.Interface
? type.AllInterfaces.Insert(0, type)
: type.AllInterfaces;
foreach (var iface in interfaces)
{
foreach (var member in iface.GetMembers())
{
if (member.IsStatic || !member.IsAbstract)
continue;
var implementation = type.FindImplementationForInterfaceMember(member);
if (implementation != null && !implementation.IsAbstract)
continue;

var inaccessible = member switch
{
IPropertySymbol property => new[] { property.GetMethod, property.SetMethod }
.FirstOrDefault(accessor => accessor != null && accessor.IsAbstract &&
!compilation.IsSymbolAccessibleWithin(accessor, compilation.Assembly)),
IEventSymbol ev => new[] { ev.AddMethod, ev.RemoveMethod }
.FirstOrDefault(accessor => accessor != null && accessor.IsAbstract &&
!compilation.IsSymbolAccessibleWithin(accessor, compilation.Assembly)),
IMethodSymbol method when method.MethodKind == MethodKind.Ordinary &&
!compilation.IsSymbolAccessibleWithin(method, compilation.Assembly) => method,
_ => null,
};
if (inaccessible != null)
return inaccessible;
}
}

return null;
}
}
37 changes: 37 additions & 0 deletions src/Stunts.CodeAnalysis/RuntimeSignature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Linq;
using Microsoft.CodeAnalysis;

namespace Stunts.CodeAnalysis;

static class RuntimeSignature
{
public static ISymbol? UnsupportedMember(INamedTypeSymbol type, Compilation compilation)
{
var types = type.TypeKind == TypeKind.Interface
? type.AllInterfaces.Insert(0, type)
: type.AllInterfaces.Add(type);
foreach (var current in types)
{
for (var declaring = current; declaring != null; declaring = declaring.BaseType)
{
foreach (var member in declaring.GetMembers())
{
if (member.IsStatic || member.IsSealed ||
!(member.IsVirtual || member.IsAbstract || member.IsOverride ||
member is IMethodSymbol { MethodKind: MethodKind.Constructor }) ||
member.DeclaredAccessibility is Accessibility.Private or Accessibility.ProtectedAndInternal ||
member.DeclaredAccessibility == Accessibility.Internal && !declaring.ContainingAssembly.GivesAccessTo(compilation.Assembly))
continue;
if (member is IMethodSymbol method &&
(Unsupported(method.ReturnType) || method.Parameters.Any(parameter => Unsupported(parameter.Type))))
return member;
}
}
}

return null;
}

static bool Unsupported(ITypeSymbol type)
=> type.SpecialType is SpecialType.System_TypedReference or SpecialType.System_ArgIterator or SpecialType.System_RuntimeArgumentHandle;
}
26 changes: 26 additions & 0 deletions src/Stunts.CodeAnalysis/StuntDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,31 @@ public static class StuntDiagnostics
DiagnosticSeverity.Error,
true,
Strings.GeneratorAttributeNotFound.Description);

/// <summary>
/// Diagnostic reported when an abstract interface member cannot be implemented
/// from the assembly containing the stunt.
/// </summary>
public static DiagnosticDescriptor InaccessibleInterfaceMember { get; } = new DiagnosticDescriptor(
"ST012",
"Interface member is inaccessible",
"'{0}' cannot be proxied because abstract interface member '{1}' is inaccessible to this assembly",
"Build",
DiagnosticSeverity.Error,
true,
"All abstract interface members, including property and event accessors, must be accessible to the generated implementation.");

/// <summary>
/// Diagnostic reported when a signature uses a special CLR type that cannot
/// be stored in invocation arguments or ref-struct holders.
/// </summary>
public static DiagnosticDescriptor UnsupportedRuntimeSignature { get; } = new DiagnosticDescriptor(
"ST013",
"Unsupported CLR signature",
"'{0}' cannot be proxied because '{1}' uses TypedReference, ArgIterator, or RuntimeArgumentHandle",
"Build",
DiagnosticSeverity.Error,
true,
"These special CLR types cannot be boxed or used as generic arguments, so the behavior pipeline cannot intercept them.");
}
}
3 changes: 3 additions & 0 deletions src/Stunts.CodeAnalysis/UnsafeSignatureAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ void AnalyzeOperation(OperationAnalysisContext context)
if (!invocation.TargetMethod.GetAttributes().Any(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, generator)))
return;

if (GenericFactory.UsesTemplate(invocation.TargetMethod, context.Compilation, generator, context.CancellationToken))
return;

if (BuildProperties.CompileTimeStuntsAndUnsafe(context.Options.AnalyzerConfigOptionsProvider.GlobalOptions))
return;

Expand Down
Loading