From 0e6c9bf3be881a5ec66f3b27d063cb6263d4d893 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 09:41:38 +0000
Subject: [PATCH 1/6] Initial plan
From f37ffc0445a0a6b66acf2cf1a5bee5d8b5261e17 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 09:54:59 +0000
Subject: [PATCH 2/6] feat: add AOT and trim compatibility to Core and
CommandRunner libraries
Co-authored-by: leeoades <2321091+leeoades@users.noreply.github.com>
---
.../FunctionalStateMachine.CommandRunner.csproj | 10 ++++++++--
.../FunctionalStateMachine.Core.csproj | 9 ++++++---
.../StateMachineAnalysis.cs | 17 +++++++++++++++++
3 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj b/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj
index 114ccdf..4f5b5ec 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,17 @@
true
$(NoWarn);CS1591
+
+
+ true
-
+
+
+
+
diff --git a/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj b/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
index 0477746..1a48bdc 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,10 +12,13 @@
true
$(NoWarn);CS1591
+
+
+ true
-
+
diff --git a/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs b/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
index ab4a541..53e14fd 100644
--- a/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
+++ b/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace FunctionalStateMachine.Core;
@@ -26,6 +27,13 @@ internal static class StateMachineAnalyzer
///
/// Analyze the state machine configuration for potential issues.
///
+#if NET8_0_OR_GREATER
+ [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode",
+ Justification = "AnalyzeUnusedTriggers uses reflection to scan for derived trigger types. " +
+ "In trimmed builds, trigger types removed by the trimmer won't be found, " +
+ "so no 'unused trigger' warnings will be emitted for them. This is acceptable " +
+ "since trimmed types are genuinely unused by the application.")]
+#endif
public static AnalysisResult Analyze(
IReadOnlyDictionary.StateDefinition> states,
TState initialState)
@@ -421,6 +429,9 @@ private static void AnalyzeDeadEndStates(
///
/// Detect trigger types that are defined but never used in any transition.
///
+#if NET8_0_OR_GREATER
+ [RequiresUnreferencedCode("Uses reflection to discover derived trigger types in the assembly")]
+#endif
private static void AnalyzeUnusedTriggers(
IReadOnlyDictionary.StateDefinition> states,
AnalysisResult result)
@@ -463,6 +474,9 @@ private static void AnalyzeUnusedTriggers(
}
}
+#if NET8_0_OR_GREATER
+ [RequiresUnreferencedCode("Uses Assembly.GetTypes() which may not return all types in trimmed builds")]
+#endif
private static HashSet GetAllTriggerTypes(Type triggerType)
{
var types = new HashSet();
@@ -494,6 +508,9 @@ private static HashSet GetAllTriggerTypes(Type triggerType)
return types;
}
+#if NET8_0_OR_GREATER
+ [RequiresUnreferencedCode("Uses Type.GetProperty() to inspect non-public properties, which may be removed by trimming")]
+#endif
private static bool IsRecordType(Type type)
{
// Records are detected by checking for the generated 'EqualityContract' property
From f256760a7aa6d15905006f90d20e958f7bc8fa51 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Feb 2026 21:50:00 +0000
Subject: [PATCH 3/6] feat: replace reflection in StateMachineAnalysis with
source-generated trigger type registry
Co-authored-by: leeoades <2321091+leeoades@users.noreply.github.com>
---
FunctionalStateMachine.sln | 15 +
...nctionalStateMachine.Core.Generator.csproj | 18 ++
.../TriggerTypeGenerator.cs | 262 ++++++++++++++++++
.../FunctionalStateMachine.Core.csproj | 18 ++
.../StateMachineAnalysis.cs | 85 +-----
.../TriggerTypeRegistry.cs | 36 +++
.../FunctionalStateMachine.Core.Tests.csproj | 3 +
.../TriggerTypeRegistryTests.cs | 73 +++++
8 files changed, 439 insertions(+), 71 deletions(-)
create mode 100644 src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
create mode 100644 src/FunctionalStateMachine.Core.Generator/TriggerTypeGenerator.cs
create mode 100644 src/FunctionalStateMachine.Core/TriggerTypeRegistry.cs
create mode 100644 test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
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/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj b/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
new file mode 100644
index 0000000..d260d0b
--- /dev/null
+++ b/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
@@ -0,0 +1,18 @@
+
+
+
+ netstandard2.0
+ latest
+ enable
+ true
+ true
+ $(NoWarn);RS1035
+ 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 1a48bdc..46ad123 100644
--- a/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
+++ b/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
@@ -22,4 +22,22 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs b/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
index 53e14fd..923cc11 100644
--- a/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
+++ b/src/FunctionalStateMachine.Core/StateMachineAnalysis.cs
@@ -1,6 +1,3 @@
-using System.Diagnostics.CodeAnalysis;
-using System.Reflection;
-
namespace FunctionalStateMachine.Core;
///
@@ -27,13 +24,6 @@ internal static class StateMachineAnalyzer
///
/// Analyze the state machine configuration for potential issues.
///
-#if NET8_0_OR_GREATER
- [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode",
- Justification = "AnalyzeUnusedTriggers uses reflection to scan for derived trigger types. " +
- "In trimmed builds, trigger types removed by the trimmer won't be found, " +
- "so no 'unused trigger' warnings will be emitted for them. This is acceptable " +
- "since trimmed types are genuinely unused by the application.")]
-#endif
public static AnalysisResult Analyze(
IReadOnlyDictionary.StateDefinition> states,
TState initialState)
@@ -428,17 +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.
///
-#if NET8_0_OR_GREATER
- [RequiresUnreferencedCode("Uses reflection to discover derived trigger types in the assembly")]
-#endif
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..e19d701
--- /dev/null
+++ b/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
@@ -0,0 +1,73 @@
+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);
+ }
+}
+
+// Types are internal at namespace level so the source generator can access them
+internal enum AccessibleState { A, B }
+
+internal abstract record AccessibleTrigger
+{
+ public sealed record TriggerA : AccessibleTrigger;
+ public sealed record TriggerB : AccessibleTrigger;
+}
+
+internal sealed record AccessibleData;
+internal abstract record AccessibleCommand;
From 65bc37e74914241cbdb5734d012626556b46b794 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Feb 2026 06:53:47 +0000
Subject: [PATCH 4/6] feat: multiple-machine/sub-machine tests, AOT trim fix
for samples, Core.Generator added to samples
Co-authored-by: leeoades <2321091+leeoades@users.noreply.github.com>
---
.../FunctionalStateMachine.Samples.csproj | 3 +
.../StockPurchaserSampleApp.csproj | 4 +
.../VendingMachineSampleApp.csproj | 4 +
...tateMachine.CommandRunner.Generator.csproj | 5 +
...unctionalStateMachine.CommandRunner.csproj | 3 +
...nctionalStateMachine.Core.Generator.csproj | 5 +
.../FunctionalStateMachine.Core.csproj | 3 +
.../FunctionalStateMachine.Diagrams.csproj | 5 +
.../TriggerTypeRegistryTests.cs | 135 ++++++++++++++++++
9 files changed, 167 insertions(+)
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 4f5b5ec..dc11805 100644
--- a/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj
+++ b/src/FunctionalStateMachine.CommandRunner/FunctionalStateMachine.CommandRunner.csproj
@@ -15,6 +15,9 @@
true
+
+ false
+ false
diff --git a/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj b/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
index d260d0b..f9f3428 100644
--- a/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
+++ b/src/FunctionalStateMachine.Core.Generator/FunctionalStateMachine.Core.Generator.csproj
@@ -8,6 +8,11 @@
true
$(NoWarn);RS1035
false
+
+
+ false
+ false
+ false
diff --git a/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj b/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
index 46ad123..9ce2547 100644
--- a/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
+++ b/src/FunctionalStateMachine.Core/FunctionalStateMachine.Core.csproj
@@ -15,6 +15,9 @@
true
+
+ false
+ false
diff --git a/src/FunctionalStateMachine.Diagrams/FunctionalStateMachine.Diagrams.csproj b/src/FunctionalStateMachine.Diagrams/FunctionalStateMachine.Diagrams.csproj
index 071b260..7a30a45 100644
--- a/src/FunctionalStateMachine.Diagrams/FunctionalStateMachine.Diagrams.csproj
+++ b/src/FunctionalStateMachine.Diagrams/FunctionalStateMachine.Diagrams.csproj
@@ -17,6 +17,11 @@
$(NoWarn);NU5128
+
+
+ false
+ false
+ false
diff --git a/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs b/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
index e19d701..21a3ab6 100644
--- a/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
+++ b/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
@@ -58,10 +58,145 @@ public void AnalyzeUnusedTriggers_NoWarning_WhenAllTriggersUsed()
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
+ }
+
+ // ── Hierarchical (sub-state) machines with trigger registry ──────────────
+
+ [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
{
From 5fc2462c65be0f6de44859bc3e26bba889d42d61 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Feb 2026 07:13:55 +0000
Subject: [PATCH 5/6] feat: add missing multi-machine unused-trigger test and
AOT/compat docs
Co-authored-by: leeoades <2321091+leeoades@users.noreply.github.com>
---
README.md | 9 ++
docs/AOT-and-Trim-Compatibility.md | 135 ++++++++++++++++++
docs/Target-Framework-Compatibility.md | 48 +++++++
docs/index.md | 5 +
.../TriggerTypeRegistryTests.cs | 40 +++++-
5 files changed, 236 insertions(+), 1 deletion(-)
create mode 100644 docs/AOT-and-Trim-Compatibility.md
create mode 100644 docs/Target-Framework-Compatibility.md
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..97c41b7
--- /dev/null
+++ b/docs/AOT-and-Trim-Compatibility.md
@@ -0,0 +1,135 @@
+# 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
+
+
+```
+
+Reference the source generator so the trigger registry is populated at startup:
+
+```xml
+
+
+
+
+```
+
+When consuming the NuGet package, the generator is automatically included as an analyzer — no extra configuration 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.
+
+## Trim propagation to analyzer projects
+
+If you reference the library projects (rather than NuGet packages) from an application that sets `PublishTrimmed=true`, the trim flag propagates to referenced projects. The analyzer-only projects (`Diagrams`, `Core.Generator`, `CommandRunner.Generator`) handle this correctly: they set `false` so they are never part of the published output.
+
+## 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/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs b/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
index 21a3ab6..fd01095 100644
--- a/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
+++ b/test/FunctionalStateMachine.Core.Tests/TriggerTypeRegistryTests.cs
@@ -151,7 +151,45 @@ public void MultipleMachines_SameTrigger_MachinesAreIndependent()
Assert.Equal(MultiAlphaState.Y, s2); // machine2 still at Y
}
- // ── Hierarchical (sub-state) machines with trigger registry ──────────────
+ [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()
From 43ffdf0c45a025fb2443ea54bd560201326c05e6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:43:09 +0000
Subject: [PATCH 6/6] docs: simplify AOT doc to NuGet-only perspective, remove
project-reference-specific sections
Co-authored-by: leeoades <2321091+leeoades@users.noreply.github.com>
---
docs/AOT-and-Trim-Compatibility.md | 15 +--------------
1 file changed, 1 insertion(+), 14 deletions(-)
diff --git a/docs/AOT-and-Trim-Compatibility.md b/docs/AOT-and-Trim-Compatibility.md
index 97c41b7..770dced 100644
--- a/docs/AOT-and-Trim-Compatibility.md
+++ b/docs/AOT-and-Trim-Compatibility.md
@@ -34,16 +34,7 @@ Add `true` to your `.csproj`:
```
-Reference the source generator so the trigger registry is populated at startup:
-
-```xml
-
-
-
-
-```
-
-When consuming the NuGet package, the generator is automatically included as an analyzer — no extra configuration needed.
+The source generator is bundled inside the `FunctionalStateMachine.Core` NuGet package and applied automatically — no extra package reference needed.
Then publish:
@@ -124,10 +115,6 @@ var machineB = StateMachine.Create()
Both machines build successfully — unused-trigger warnings are informational and never block construction.
-## Trim propagation to analyzer projects
-
-If you reference the library projects (rather than NuGet packages) from an application that sets `PublishTrimmed=true`, the trim flag propagates to referenced projects. The analyzer-only projects (`Diagrams`, `Core.Generator`, `CommandRunner.Generator`) handle this correctly: they set `false` so they are never part of the published output.
-
## Related pages
- [Target Framework Compatibility](./Target-Framework-Compatibility.md)