diff --git a/FunctionalStateMachine.sln b/FunctionalStateMachine.sln
index 70d1f97..8d59a9d 100644
--- a/FunctionalStateMachine.sln
+++ b/FunctionalStateMachine.sln
@@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalStateMachine.Benc
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalStateMachine.Core.Generator", "src\FunctionalStateMachine.Core.Generator\FunctionalStateMachine.Core.Generator.csproj", "{FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -231,6 +233,18 @@ Global
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x64.Build.0 = Release|Any CPU
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x86.ActiveCfg = Release|Any CPU
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555}.Release|x86.Build.0 = Release|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x64.Build.0 = Debug|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Debug|x86.Build.0 = Debug|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x64.ActiveCfg = Release|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x64.Build.0 = Release|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x86.ActiveCfg = Release|Any CPU
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -251,5 +265,6 @@ Global
{D1FA70BE-F53F-492A-83E3-A1D34267795E} = {A96AD5CA-C4AA-45C6-A86D-69F8ED944894}
{E5866FE3-15DF-4362-95A6-C6F651434249} = {A96AD5CA-C4AA-45C6-A86D-69F8ED944894}
{C2FBB31B-EE96-4ECB-B077-6D9313DB1555} = {0C88DD14-F956-CE84-757C-A364CCF449FC}
+ {FE472548-CAE7-4E2A-A6B5-0DDC70D7FCD4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 46316ed..d26fc72 100644
--- a/README.md
+++ b/README.md
@@ -485,6 +485,15 @@ Explore complete, runnable examples in the `/samples` directory:
---
+## Advanced Topics
+
+This library targets both `netstandard2.0` (broad compatibility) and `net8.0` (AOT/trim-ready). Both NativeAOT and `PublishTrimmed` publishing are supported with zero runtime reflection.
+
+- **[Target framework compatibility](docs/Target-Framework-Compatibility.md)** — .NET Standard 2.0 vs .NET 8+, what each target provides
+- **[AOT and trim compatibility](docs/AOT-and-Trim-Compatibility.md)** — NativeAOT, `PublishTrimmed`, how source generators eliminate reflection
+
+---
+
## Installation
```bash
diff --git a/docs/AOT-and-Trim-Compatibility.md b/docs/AOT-and-Trim-Compatibility.md
new file mode 100644
index 0000000..770dced
--- /dev/null
+++ b/docs/AOT-and-Trim-Compatibility.md
@@ -0,0 +1,122 @@
+# AOT and Trim Compatibility
+
+The `net8.0` build of `FunctionalStateMachine.Core` and `FunctionalStateMachine.CommandRunner` is fully compatible with:
+
+- **NativeAOT** (`PublishAot=true`) — compiled ahead of time to a self-contained native binary
+- **Trimming** (`PublishTrimmed=true`) — unused code removed at publish time to reduce binary size
+- **Single-file publishing** (`PublishSingleFile=true`)
+
+## Status
+
+| Package | `IsAotCompatible` | Reflection-free | Trim-safe |
+|---|---|---|---|
+| `FunctionalStateMachine.Core` | ✅ (`net8.0+`) | ✅ | ✅ |
+| `FunctionalStateMachine.CommandRunner` | ✅ (`net8.0+`) | ✅ | ✅ |
+| `FunctionalStateMachine.Diagrams` | N/A (build-time only) | N/A | N/A |
+| `FunctionalStateMachine.Core.Generator` | N/A (build-time only) | N/A | N/A |
+| `FunctionalStateMachine.CommandRunner.Generator` | N/A (build-time only) | N/A | N/A |
+
+The two generators (`Core.Generator` and `CommandRunner.Generator`) are Roslyn analyzers that run at compile time inside the compiler process, not in your application. They are never published as part of your app binary.
+
+## Enabling publishing with trimming
+
+### Executable projects
+
+Add `true` to your `.csproj`:
+
+```xml
+
+
+ Exe
+ net9.0
+ true
+
+
+```
+
+The source generator is bundled inside the `FunctionalStateMachine.Core` NuGet package and applied automatically — no extra package reference needed.
+
+Then publish:
+
+```bash
+dotnet publish -c Release -r linux-x64 --sc true
+```
+
+### NativeAOT
+
+```xml
+
+ true
+
+```
+
+```bash
+dotnet publish -c Release -r linux-x64
+```
+
+## How it works: no reflection at runtime
+
+The library was originally written to warn about unused trigger types at state machine build time. This required knowing all defined trigger subtypes — which was initially discovered via `Assembly.GetTypes()` and `Type.GetProperty()` (reflection).
+
+Both reflection APIs are incompatible with AOT/trimming because the trimmer may remove types it doesn't see referenced, and `Assembly.GetTypes()` only returns what survives trimming.
+
+### The source generator approach
+
+The `FunctionalStateMachine.Core.Generator` Roslyn source generator solves this at compile time:
+
+1. It detects all `StateMachine<…, TTrigger, …>.Create()` call sites in your compilation.
+2. For each unique `TTrigger`, it finds all non-abstract concrete derived types using the Roslyn symbol API (no runtime reflection).
+3. It generates a `[ModuleInitializer]` that registers those types in `TriggerTypeRegistry` before any app code runs.
+
+```csharp
+// Example of generated output (in your bin/obj folder):
+//
+[ModuleInitializer]
+internal static void Initialize()
+{
+ TriggerTypeRegistry.Register(new[]
+ {
+ typeof(global::MyApp.OrderTrigger.Process),
+ typeof(global::MyApp.OrderTrigger.Cancel),
+ typeof(global::MyApp.OrderTrigger.Complete),
+ });
+}
+```
+
+At runtime, `AnalyzeUnusedTriggers` reads from this registry — no assembly scanning, no reflection. If the registry has not been populated (e.g. the generator wasn't active for that trigger type), the check is silently skipped rather than throwing.
+
+### CommandRunner dispatcher
+
+`FunctionalStateMachine.CommandRunner` also uses a source generator (`CommandRunner.Generator`) to produce a type-switching dispatcher at compile time. The dispatcher uses a `switch` on concrete command types rather than any reflection-based dispatch, making it fully AOT- and trim-safe.
+
+## Multiple state machines sharing the same trigger type
+
+When several state machines in the same project share the same `TTrigger`, the generator registers the trigger types once (deduplicated by trigger base type). Each machine's unused-trigger analysis runs independently at `.Build()` time:
+
+```csharp
+// Both machines share OrderTrigger; generator registers its types once.
+// machineA may use all triggers; machineB may only use a subset.
+// Each machine independently warns about its own unused triggers.
+
+var machineA = StateMachine.Create()
+ .For(StateA.Idle)
+ .On() // uses Process
+ .TransitionTo(StateA.Done)
+ // ⚠️ warning: Cancel and Complete unused in this machine
+ .Build();
+
+var machineB = StateMachine.Create()
+ .For(StateB.Idle)
+ .On() // uses Cancel
+ .TransitionTo(StateB.Cancelled)
+ // ⚠️ warning: Process and Complete unused in this machine
+ .Build();
+```
+
+Both machines build successfully — unused-trigger warnings are informational and never block construction.
+
+## Related pages
+
+- [Target Framework Compatibility](./Target-Framework-Compatibility.md)
+- [Packages](./Packages.md)
+- [Static Analysis](./Static-Analysis-for-State-Machine-Configuration.md)
diff --git a/docs/Target-Framework-Compatibility.md b/docs/Target-Framework-Compatibility.md
new file mode 100644
index 0000000..90e6e76
--- /dev/null
+++ b/docs/Target-Framework-Compatibility.md
@@ -0,0 +1,48 @@
+# Target Framework Compatibility
+
+`FunctionalStateMachine.Core` and `FunctionalStateMachine.CommandRunner` both multi-target so the correct build is selected automatically based on your project's target framework.
+
+## Supported targets
+
+| Target framework | Gets |
+|---|---|
+| `netstandard2.0` | Broadest compatibility — .NET Framework 4.6.1+, .NET Core 2.0+, Xamarin, Unity |
+| `net8.0` | AOT-compatible build with full trim analysis |
+
+When you reference the NuGet package from a `net8.0` or `net9.0` project, NuGet automatically selects the `net8.0` build. When you reference it from a `netstandard2.0`-compatible target, NuGet selects the `netstandard2.0` build.
+
+## Feature comparison
+
+All state machine features are identical across both targets. The only difference is in the internals:
+
+| Feature | `netstandard2.0` | `net8.0+` |
+|---|---|---|
+| Full fluent API | ✅ | ✅ |
+| Guards, conditionals | ✅ | ✅ |
+| Hierarchical states | ✅ | ✅ |
+| Static analysis on `.Build()` | ✅ | ✅ |
+| Unused-trigger analysis | ✅ (when generator active) | ✅ (when generator active) |
+| AOT / NativeAOT safe | ⚠️ (N/A for `netstandard2.0`) | ✅ |
+| Trim-safe | ⚠️ (N/A for `netstandard2.0`) | ✅ |
+| `[ModuleInitializer]` for trigger registry | ✅ (when PolySharp present) | ✅ |
+
+## Source generator compatibility
+
+The source generator (`FunctionalStateMachine.Core.Generator`) is a `netstandard2.0` Roslyn analyzer and works with all target frameworks. The generated `[ModuleInitializer]` code requires the `System.Runtime.CompilerServices.ModuleInitializerAttribute` type, which is:
+
+- Available natively in `.NET 5` and later
+- Back-ported to `netstandard2.0` by [PolySharp](https://github.com/Sergio0694/PolySharp) if you have it installed
+
+If neither is available (e.g. plain `netstandard2.0` without PolySharp), the generator silently skips generation and unused-trigger analysis is bypassed without error.
+
+## Using with .NET Framework
+
+If your project targets `.NET Framework`, the `netstandard2.0` build is used. The state machine works fully, but:
+
+- Unused-trigger analysis will not report warnings (no `[ModuleInitializer]` support without PolySharp)
+- AOT and trim tooling are not applicable
+
+## Related pages
+
+- [AOT and Trim Compatibility](./AOT-and-Trim-Compatibility.md)
+- [Packages](./Packages.md)
diff --git a/docs/index.md b/docs/index.md
index f8d1592..8007ca2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -31,6 +31,11 @@ Welcome to the Functional State Machine docs. Each guide introduces a feature, w
- [Mermaid diagram generation](Mermaid-Diagram-Generation.md)
- [Command runners](Command-Runners.md)
+## Advanced
+
+- [Target framework compatibility (.NET Standard 2.0 vs .NET 8+)](Target-Framework-Compatibility.md)
+- [AOT and trim compatibility](AOT-and-Trim-Compatibility.md)
+
## For Contributors
- [AI documentation maintenance guide](AI-Documentation-Maintenance.md) - How to keep AI docs up-to-date
diff --git a/samples/Basic/FunctionalStateMachine.Samples/FunctionalStateMachine.Samples.csproj b/samples/Basic/FunctionalStateMachine.Samples/FunctionalStateMachine.Samples.csproj
index df12372..315b345 100644
--- a/samples/Basic/FunctionalStateMachine.Samples/FunctionalStateMachine.Samples.csproj
+++ b/samples/Basic/FunctionalStateMachine.Samples/FunctionalStateMachine.Samples.csproj
@@ -20,6 +20,9 @@
+
diff --git a/samples/StockPurchaser/StockPurchaserSampleApp/StockPurchaserSampleApp.csproj b/samples/StockPurchaser/StockPurchaserSampleApp/StockPurchaserSampleApp.csproj
index 48e89de..0be1173 100644
--- a/samples/StockPurchaser/StockPurchaserSampleApp/StockPurchaserSampleApp.csproj
+++ b/samples/StockPurchaser/StockPurchaserSampleApp/StockPurchaserSampleApp.csproj
@@ -5,10 +5,14 @@
net9.0
enable
enable
+ true
+
diff --git a/samples/VendingMachine/VendingMachineSampleApp/VendingMachineSampleApp.csproj b/samples/VendingMachine/VendingMachineSampleApp/VendingMachineSampleApp.csproj
index c338ad1..a4435a1 100644
--- a/samples/VendingMachine/VendingMachineSampleApp/VendingMachineSampleApp.csproj
+++ b/samples/VendingMachine/VendingMachineSampleApp/VendingMachineSampleApp.csproj
@@ -5,6 +5,7 @@
net9.0
enable
enable
+ true
@@ -17,6 +18,9 @@
ReferenceOutputAssembly="false" />
+
diff --git a/src/FunctionalStateMachine.CommandRunner.Generator/FunctionalStateMachine.CommandRunner.Generator.csproj b/src/FunctionalStateMachine.CommandRunner.Generator/FunctionalStateMachine.CommandRunner.Generator.csproj
index d260d0b..f9f3428 100644
--- a/src/FunctionalStateMachine.CommandRunner.Generator/FunctionalStateMachine.CommandRunner.Generator.csproj
+++ b/src/FunctionalStateMachine.CommandRunner.Generator/FunctionalStateMachine.CommandRunner.Generator.csproj
@@ -8,6 +8,11 @@
true
$(NoWarn);RS1035
false
+
+
+ false
+ false
+ false
diff --git a/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj b/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj
index 114ccdf..dc11805 100644
--- a/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj
+++ b/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj
@@ -1,7 +1,7 @@
- netstandard2.0
+ netstandard2.0;net8.0
latest
enable
enable
@@ -12,11 +12,20 @@
true
$(NoWarn);CS1591
+
+
+ true
+
+ false
+ false
-
+
+
+
+
diff --git a/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj b/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
new file mode 100644
index 0000000..f9f3428
--- /dev/null
+++ b/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
@@ -0,0 +1,23 @@
+
+
+
+ netstandard2.0
+ latest
+ enable
+ true
+ true
+ $(NoWarn);RS1035
+ false
+
+
+ false
+ false
+ false
+
+
+
+
+
+
+
+
diff --git a/src/FunctionalStateMachine.Core.Generator/TriggerTypeGenerator.cs b/src/FunctionalStateMachine.Core.Generator/TriggerTypeGenerator.cs
new file mode 100644
index 0000000..aaaa0f8
--- /dev/null
+++ b/src/FunctionalStateMachine.Core.Generator/TriggerTypeGenerator.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace FunctionalStateMachine.Core.Generator;
+
+[Generator]
+public sealed class TriggerTypeGenerator : IIncrementalGenerator
+{
+ private const string CreateMethodName = "Create";
+ // StateMachine — the 4-param with-data variant
+ private const string StateMachineMetadataName4 = "StateMachine`4";
+ // StateMachine — the 3-param NoData variant
+ private const string StateMachineMetadataName3 = "StateMachine`3";
+ private const string StateMachineNamespace = "FunctionalStateMachine.Core";
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var triggerTypes = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ static (node, _) => IsCreateInvocation(node),
+ static (ctx, _) => GetTriggerType(ctx))
+ .Where(static symbol => symbol is not null)
+ .Select(static (symbol, _) => symbol!)
+ .Collect();
+
+ var combined = triggerTypes.Combine(context.CompilationProvider);
+ context.RegisterSourceOutput(combined, static (ctx, data) =>
+ {
+ var (triggerTypeSymbols, compilation) = data;
+ if (triggerTypeSymbols.IsDefaultOrEmpty)
+ {
+ return;
+ }
+
+ Generate(ctx, compilation, triggerTypeSymbols);
+ });
+ }
+
+ private static bool IsCreateInvocation(SyntaxNode node)
+ {
+ if (node is not InvocationExpressionSyntax invocation)
+ {
+ return false;
+ }
+
+ return invocation.Expression is MemberAccessExpressionSyntax
+ {
+ Name: { Identifier.ValueText: CreateMethodName }
+ };
+ }
+
+ private static INamedTypeSymbol? GetTriggerType(GeneratorSyntaxContext context)
+ {
+ if (context.Node is not InvocationExpressionSyntax invocation)
+ {
+ return null;
+ }
+
+ var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
+ if (symbolInfo.Symbol is not IMethodSymbol { Name: CreateMethodName } methodSymbol)
+ {
+ return null;
+ }
+
+ var containingType = methodSymbol.ContainingType;
+ if (containingType is null)
+ {
+ return null;
+ }
+
+ // Match both 4-param (with data) and 3-param (NoData) StateMachine
+ var metadataName = containingType.OriginalDefinition.MetadataName;
+ if (!string.Equals(metadataName, StateMachineMetadataName4, StringComparison.Ordinal) &&
+ !string.Equals(metadataName, StateMachineMetadataName3, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ if (!string.Equals(
+ containingType.ContainingNamespace?.ToDisplayString(),
+ StateMachineNamespace,
+ StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ // TTrigger is always the 2nd type argument (index 1)
+ if (containingType.TypeArguments.Length < 2)
+ {
+ return null;
+ }
+
+ return containingType.TypeArguments[1] as INamedTypeSymbol;
+ }
+
+ private static void Generate(
+ SourceProductionContext context,
+ Compilation compilation,
+ IReadOnlyList triggerBaseTypes)
+ {
+ // Only generate if [ModuleInitializer] is available in the target framework
+ var moduleInitAttr = compilation.GetTypeByMetadataName(
+ "System.Runtime.CompilerServices.ModuleInitializerAttribute");
+ if (moduleInitAttr is null)
+ {
+ return;
+ }
+
+ // Collect all types in the current assembly (including nested)
+ var allTypes = new List();
+ CollectTypes(compilation.Assembly.GlobalNamespace, allTypes);
+
+ // Deduplicate trigger base types
+ var uniqueTriggerTypes = new List();
+ var seen = new HashSet(SymbolEqualityComparer.Default);
+ foreach (var t in triggerBaseTypes)
+ {
+ if (seen.Add(t))
+ {
+ uniqueTriggerTypes.Add(t);
+ }
+ }
+
+ var source = new StringBuilder();
+ source.AppendLine("// ");
+ source.AppendLine("using System;");
+ source.AppendLine("using System.Runtime.CompilerServices;");
+ source.AppendLine("using FunctionalStateMachine.Core;");
+ source.AppendLine();
+ source.AppendLine("namespace FunctionalStateMachine.Core.Generated");
+ source.AppendLine("{");
+ source.AppendLine(" internal static class TriggerTypeRegistrationBootstrap");
+ source.AppendLine(" {");
+ source.AppendLine(" [ModuleInitializer]");
+ source.AppendLine(" internal static void Initialize()");
+ source.AppendLine(" {");
+
+ foreach (var triggerBaseType in uniqueTriggerTypes)
+ {
+ // Skip trigger types that are not accessible from the generated module initializer
+ if (!IsAccessibleFromAssembly(triggerBaseType))
+ {
+ continue;
+ }
+
+ var concreteTypes = GetConcreteTypes(triggerBaseType, allTypes);
+
+ // Nothing to register if no accessible concrete types were found
+ if (concreteTypes.Count == 0)
+ {
+ continue;
+ }
+
+ var baseTypeName = triggerBaseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var typeArray = string.Join(", ", concreteTypes.Select(t =>
+ $"typeof({t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)})"));
+ source.AppendLine(
+ $" TriggerTypeRegistry.Register<{baseTypeName}>(new[] {{ {typeArray} }});");
+ }
+
+ source.AppendLine(" }");
+ source.AppendLine(" }");
+ source.AppendLine("}");
+
+ context.AddSource("TriggerTypeRegistry.g.cs", source.ToString());
+ }
+
+ private static List GetConcreteTypes(
+ INamedTypeSymbol baseType,
+ List allTypes)
+ {
+ // For enum TTrigger, register the enum type itself (no subtypes)
+ if (baseType.TypeKind == TypeKind.Enum)
+ {
+ return new List { baseType };
+ }
+
+ // Find all non-abstract, non-generic types that derive from the base type
+ // Only include types that are accessible from the generated module initializer
+ var derivedTypes = allTypes
+ .Where(t => !t.IsAbstract && t.TypeParameters.IsEmpty)
+ .Where(t => !SymbolEqualityComparer.Default.Equals(t, baseType) && IsAssignableTo(t, baseType))
+ .Where(IsAccessibleFromAssembly)
+ .OrderBy(t => t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), StringComparer.Ordinal)
+ .ToList();
+
+ // If no derived types found, register the base type itself as a fallback
+ // (only if the base type itself is accessible)
+ if (derivedTypes.Count == 0 && IsAccessibleFromAssembly(baseType))
+ {
+ return new List { baseType };
+ }
+
+ return derivedTypes;
+ }
+
+ ///
+ /// Returns true if the type and all its containing types are at least internal,
+ /// making them accessible from the generated module initializer class in the same assembly.
+ ///
+ private static bool IsAccessibleFromAssembly(INamedTypeSymbol type)
+ {
+ var current = type;
+ while (current is not null)
+ {
+ switch (current.DeclaredAccessibility)
+ {
+ case Accessibility.Private:
+ case Accessibility.Protected:
+ case Accessibility.ProtectedAndInternal:
+ return false;
+ }
+
+ current = current.ContainingType;
+ }
+
+ return true;
+ }
+
+ private static bool IsAssignableTo(INamedTypeSymbol type, INamedTypeSymbol baseType)
+ {
+ var current = type.BaseType;
+ while (current is not null)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, baseType.OriginalDefinition))
+ {
+ return true;
+ }
+
+ current = current.BaseType;
+ }
+
+ return false;
+ }
+
+ private static void CollectTypes(INamespaceSymbol namespaceSymbol, List types)
+ {
+ foreach (var type in namespaceSymbol.GetTypeMembers())
+ {
+ types.Add(type);
+ CollectNestedTypes(type, types);
+ }
+
+ foreach (var nestedNamespace in namespaceSymbol.GetNamespaceMembers())
+ {
+ CollectTypes(nestedNamespace, types);
+ }
+ }
+
+ private static void CollectNestedTypes(INamedTypeSymbol type, List types)
+ {
+ foreach (var nested in type.GetTypeMembers())
+ {
+ types.Add(nested);
+ CollectNestedTypes(nested, types);
+ }
+ }
+}
diff --git a/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj b/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
index 0477746..9ce2547 100644
--- a/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
+++ b/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
@@ -1,7 +1,7 @@
-
+
- netstandard2.0
+ netstandard2.0;net8.0
latest
enable
enable
@@ -12,11 +12,35 @@
true
$(NoWarn);CS1591
+
+
+ true
+
+ false
+ false
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs b/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
index ab4a541..923cc11 100644
--- a/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
+++ b/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
@@ -1,5 +1,3 @@
-using System.Reflection;
-
namespace FunctionalStateMachine.Core;
///
@@ -420,14 +418,22 @@ private static void AnalyzeDeadEndStates(
///
/// Detect trigger types that are defined but never used in any transition.
+ /// Requires the FunctionalStateMachine.Core source generator to populate the
+ /// at startup. Silently skips if not populated.
///
private static void AnalyzeUnusedTriggers(
IReadOnlyDictionary.StateDefinition> states,
AnalysisResult result)
{
+ // Require source-generated type registry; skip if not populated
+ if (!TriggerTypeRegistry.TryGet(out var allTriggerTypes))
+ {
+ return;
+ }
+
// Collect all used trigger types
var usedTriggers = new HashSet
diff --git a/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs b/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
new file mode 100644
index 0000000..fd01095
--- /dev/null
+++ b/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
@@ -0,0 +1,246 @@
+namespace FunctionalStateMachine.Core.Tests;
+
+///
+/// Tests that the source generator populates TriggerTypeRegistry for accessible trigger types,
+/// enabling unused-trigger analysis without reflection.
+///
+public class TriggerTypeRegistryTests
+{
+ [Fact]
+ public void TriggerTypeRegistry_IsPopulatedByGenerator_ForAccessibleTrigger()
+ {
+ // The source generator should have registered the trigger types
+ // for AccessibleTrigger (internal type at namespace level)
+ // at module initialization time.
+ var registered = TriggerTypeRegistry.TryGet(out var types);
+
+ Assert.True(registered, "TriggerTypeRegistry should be populated by the source generator");
+ Assert.NotNull(types);
+ Assert.Contains(typeof(AccessibleTrigger.TriggerA), types);
+ Assert.Contains(typeof(AccessibleTrigger.TriggerB), types);
+ Assert.DoesNotContain(typeof(AccessibleTrigger), types); // abstract base should not be listed
+ }
+
+ [Fact]
+ public void AnalyzeUnusedTriggers_ReportsWarning_WhenTriggerNotUsed()
+ {
+ // TriggerA is used, TriggerB is not — should build fine (warning is logged to Debug, not thrown)
+ // Warnings are internal to AnalysisResult; the machine still builds successfully.
+ var machine = StateMachine.Create()
+ .StartWith(AccessibleState.A)
+ .For(AccessibleState.A)
+ .On()
+ .TransitionTo(AccessibleState.B)
+ .For(AccessibleState.B)
+ .On()
+ .TransitionTo(AccessibleState.A)
+ // TriggerB is never used — warning is emitted (not an error)
+ .Build();
+
+ Assert.NotNull(machine);
+ }
+
+ [Fact]
+ public void AnalyzeUnusedTriggers_NoWarning_WhenAllTriggersUsed()
+ {
+ // Both TriggerA and TriggerB are used
+ var machine = StateMachine.Create()
+ .StartWith(AccessibleState.A)
+ .For(AccessibleState.A)
+ .On()
+ .TransitionTo(AccessibleState.B)
+ .On()
+ .TransitionTo(AccessibleState.A)
+ .For(AccessibleState.B)
+ .On()
+ .TransitionTo(AccessibleState.A)
+ .Build();
+
+ Assert.NotNull(machine);
+ }
+
+ // ── Multiple machines sharing the same trigger type ──────────────────────
+
+ [Fact]
+ public void MultipleMachines_SameTrigger_RegistryPopulatedOnce()
+ {
+ // The source generator deduplicates: both machines share AccessibleTrigger,
+ // so the registry should contain exactly the concrete types, not doubled.
+ var registered = TriggerTypeRegistry.TryGet(out var types);
+
+ Assert.True(registered);
+ Assert.NotNull(types);
+ // Exactly two concrete types, not duplicated
+ Assert.Equal(2, types.Length);
+ Assert.Contains(typeof(AccessibleTrigger.TriggerA), types);
+ Assert.Contains(typeof(AccessibleTrigger.TriggerB), types);
+ }
+
+ [Fact]
+ public void MultipleMachines_SameTrigger_BothMachinesBuildAndFireCorrectly()
+ {
+ // Two independent state machines that share the same trigger hierarchy.
+ // Each machine has different state/data/command types but the same TTrigger.
+ var machineAlpha = StateMachine.Create()
+ .StartWith(MultiAlphaState.X)
+ .For(MultiAlphaState.X)
+ .On()
+ .TransitionTo(MultiAlphaState.Y)
+ .For(MultiAlphaState.Y)
+ .On()
+ .TransitionTo(MultiAlphaState.X)
+ .Build();
+
+ var machineBeta = StateMachine.Create()
+ .StartWith(MultiBetaState.P)
+ .For(MultiBetaState.P)
+ .On()
+ .TransitionTo(MultiBetaState.Q)
+ .For(MultiBetaState.Q)
+ .On()
+ .TransitionTo(MultiBetaState.P)
+ .Build();
+
+ // machineAlpha: X --TriggerA--> Y
+ var (alphaState, alphaData, _) = machineAlpha.Fire(new AccessibleTrigger.TriggerA(), MultiAlphaState.X, new AccessibleData());
+ Assert.Equal(MultiAlphaState.Y, alphaState);
+
+ // machineAlpha: Y --TriggerB--> X
+ var (alphaState2, _, _) = machineAlpha.Fire(new AccessibleTrigger.TriggerB(), alphaState, alphaData);
+ Assert.Equal(MultiAlphaState.X, alphaState2);
+
+ // machineBeta: P --TriggerB--> Q
+ var (betaState, betaData, _) = machineBeta.Fire(new AccessibleTrigger.TriggerB(), MultiBetaState.P, new AccessibleData());
+ Assert.Equal(MultiBetaState.Q, betaState);
+
+ // machineBeta: Q --TriggerA--> P
+ var (betaState2, _, _) = machineBeta.Fire(new AccessibleTrigger.TriggerA(), betaState, betaData);
+ Assert.Equal(MultiBetaState.P, betaState2);
+ }
+
+ [Fact]
+ public void MultipleMachines_SameTrigger_MachinesAreIndependent()
+ {
+ // Verify machines with shared triggers don't affect each other's state.
+ var machine1 = StateMachine.Create()
+ .StartWith(MultiAlphaState.X)
+ .For(MultiAlphaState.X)
+ .On()
+ .TransitionTo(MultiAlphaState.Y)
+ .For(MultiAlphaState.Y)
+ .On()
+ .TransitionTo(MultiAlphaState.X)
+ .Build();
+
+ var machine2 = StateMachine.Create()
+ .StartWith(MultiAlphaState.X)
+ .For(MultiAlphaState.X)
+ .On()
+ .TransitionTo(MultiAlphaState.Y)
+ .For(MultiAlphaState.Y)
+ .On()
+ .TransitionTo(MultiAlphaState.X)
+ .Build();
+
+ // Fire machine1 twice but machine2 once — their states are independent
+ var (s1, d1, _) = machine1.Fire(new AccessibleTrigger.TriggerA(), MultiAlphaState.X, new AccessibleData());
+ var (s1b, _, _) = machine1.Fire(new AccessibleTrigger.TriggerA(), s1, d1);
+ var (s2, _, _) = machine2.Fire(new AccessibleTrigger.TriggerA(), MultiAlphaState.X, new AccessibleData());
+
+ Assert.Equal(MultiAlphaState.X, s1b); // machine1 looped back
+ Assert.Equal(MultiAlphaState.Y, s2); // machine2 still at Y
+ }
+
+ [Fact]
+ public void MultipleMachines_SameTrigger_EachUsesSubsetOfTriggers_BothBuildSuccessfully()
+ {
+ // machineAlpha only uses TriggerA (TriggerB is unused in Alpha)
+ // machineBeta only uses TriggerB (TriggerA is unused in Beta)
+ // Both should build successfully — unused-trigger analysis is per-machine and
+ // emits warnings (not errors), so a trigger unused in one machine doesn't block
+ // the other machine.
+
+ var machineAlpha = StateMachine.Create()
+ .StartWith(MultiAlphaState.X)
+ .For(MultiAlphaState.X)
+ .On() // TriggerB never used in Alpha
+ .TransitionTo(MultiAlphaState.Y)
+ .For(MultiAlphaState.Y)
+ .On()
+ .TransitionTo(MultiAlphaState.X)
+ .Build(); // ⚠️ warning about TriggerB, but no error
+
+ var machineBeta = StateMachine.Create()
+ .StartWith(MultiBetaState.P)
+ .For(MultiBetaState.P)
+ .On() // TriggerA never used in Beta
+ .TransitionTo(MultiBetaState.Q)
+ .For(MultiBetaState.Q)
+ .On()
+ .TransitionTo(MultiBetaState.P)
+ .Build(); // ⚠️ warning about TriggerA, but no error
+
+ Assert.NotNull(machineAlpha);
+ Assert.NotNull(machineBeta);
+
+ // Both machines respond correctly to their own triggers
+ var (alphaNext, _, _) = machineAlpha.Fire(new AccessibleTrigger.TriggerA(), MultiAlphaState.X, new AccessibleData());
+ Assert.Equal(MultiAlphaState.Y, alphaNext);
+
+ var (betaNext, _, _) = machineBeta.Fire(new AccessibleTrigger.TriggerB(), MultiBetaState.P, new AccessibleData());
+ Assert.Equal(MultiBetaState.Q, betaNext);
+ }
+
+ [Fact]
+ public void HierarchicalMachine_TriggerRegistry_PopulatedCorrectly()
+ {
+ // Hierarchical states reuse the same TTrigger as flat machines.
+ // The trigger registry is keyed on TTrigger regardless of state hierarchy.
+ var registered = TriggerTypeRegistry.TryGet(out var types);
+ Assert.True(registered);
+ Assert.Contains(typeof(AccessibleTrigger.TriggerA), types);
+ Assert.Contains(typeof(AccessibleTrigger.TriggerB), types);
+ }
+
+ [Fact]
+ public void HierarchicalMachine_TriggersFire_ThroughParentAndChildStates()
+ {
+ // A machine with hierarchical (parent/child) states using AccessibleTrigger.
+ // Verifies that the shared trigger registry doesn't interfere with hierarchy logic.
+ var machine = StateMachine.Create()
+ .StartWith(HierarchyState.Leaf)
+ .For(HierarchyState.Root)
+ .StartsWith(HierarchyState.Child)
+ .For(HierarchyState.Child)
+ .SubStateOf(HierarchyState.Root)
+ .On()
+ .TransitionTo(HierarchyState.Leaf)
+ .For(HierarchyState.Leaf)
+ .On()
+ .TransitionTo(HierarchyState.Child)
+ .Build();
+
+ // Leaf --TriggerB--> Child (a sub-state of Root)
+ var (state1, data1, _) = machine.Fire(new AccessibleTrigger.TriggerB(), HierarchyState.Leaf, new AccessibleData());
+ Assert.Equal(HierarchyState.Child, state1);
+
+ // Child --TriggerA--> Leaf
+ var (state2, _, _) = machine.Fire(new AccessibleTrigger.TriggerA(), state1, data1);
+ Assert.Equal(HierarchyState.Leaf, state2);
+ }
+}
+
+// Types are internal at namespace level so the source generator can access them
+internal enum AccessibleState { A, B }
+internal enum MultiAlphaState { X, Y }
+internal enum MultiBetaState { P, Q }
+internal enum HierarchyState { Root, Child, Leaf }
+
+internal abstract record AccessibleTrigger
+{
+ public sealed record TriggerA : AccessibleTrigger;
+ public sealed record TriggerB : AccessibleTrigger;
+}
+
+internal sealed record AccessibleData;
+internal abstract record AccessibleCommand;