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(); - + foreach (var definition in states.Values) { foreach (var triggerKey in definition.Transitions.Keys) @@ -447,21 +442,13 @@ private static void AnalyzeUnusedTriggers( } } - // Get all possible trigger types from the TTrigger type - var triggerType = typeof(TTrigger); - - // If TTrigger is a sealed record hierarchy, find all derived types - var allTriggerTypes = GetAllTriggerTypes(triggerType); - // Find unused trigger types foreach (var possibleTrigger in allTriggerTypes) { - // Check if this trigger type is used - bool isUsed = usedTriggers.Any(t => + bool isUsed = usedTriggers.Any(t => { - // Handle both type-based and value-based triggers - if (t is Type) - return (Type)t == possibleTrigger; + if (t is Type type) + return type == possibleTrigger; return t.GetType() == possibleTrigger; }); @@ -474,53 +461,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(); - - // Add the base trigger type if it's abstract/record - if (triggerType.IsAbstract || IsRecordType(triggerType)) - { - // Find all derived types in the same assembly - var assembly = triggerType.Assembly; - foreach (var type in assembly.GetTypes()) - { - // Check if it's a record/sealed record deriving from the trigger type - if (type != triggerType && - triggerType.IsAssignableFrom(type) && - !type.IsAbstract && - IsRecordType(type)) - { - types.Add(type); - } - } - } - - // Always add the trigger type itself as a fallback - if (types.Count == 0) - { - types.Add(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 - return type.GetProperty("EqualityContract", - BindingFlags.NonPublic | BindingFlags.Instance) != null; - } - private static string GetTriggerTypeName(object triggerKey) { - // triggerKey is typically the trigger type or trigger value - return triggerKey.GetType().Name; + // triggerKey is either a Type (for On() calls) or a trigger instance (for On(value) calls) + return triggerKey is Type type ? type.Name : triggerKey.GetType().Name; } } diff --git a/src/FunctionalStateMachine.Core/TriggerTypeRegistry.cs b/src/FunctionalStateMachine.Core/TriggerTypeRegistry.cs new file mode 100644 index 0000000..a616d0a --- /dev/null +++ b/src/FunctionalStateMachine.Core/TriggerTypeRegistry.cs @@ -0,0 +1,36 @@ +using System.ComponentModel; + +namespace FunctionalStateMachine.Core; + +/// +/// Registry for source-generated trigger type mappings. +/// Populated automatically by the FunctionalStateMachine.Core source generator. +/// +/// +/// is called exclusively from [ModuleInitializer] +/// methods, which the .NET runtime guarantees run single-threaded before any module code +/// executes. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class TriggerTypeRegistry +{ + private static readonly Dictionary Registrations = new(); + + /// + /// Registers the concrete trigger types for a given trigger base type. + /// Called automatically by the source-generated module initializer. + /// + public static void Register(Type[] triggerTypes) + { + Registrations[typeof(TTrigger)] = triggerTypes ?? throw new ArgumentNullException(nameof(triggerTypes)); + } + + /// + /// Tries to retrieve the registered concrete trigger types for a given trigger base type. + /// Returns false if the generator was not active for this trigger type. + /// + public static bool TryGet(out Type[] triggerTypes) + { + return Registrations.TryGetValue(typeof(TTrigger), out triggerTypes!); + } +} diff --git a/test/FunctionalStateMachine.Core.Tests/FunctionalStateMachine.Core.Tests.csproj b/test/FunctionalStateMachine.Core.Tests/FunctionalStateMachine.Core.Tests.csproj index a2076b8..b16c7cc 100644 --- a/test/FunctionalStateMachine.Core.Tests/FunctionalStateMachine.Core.Tests.csproj +++ b/test/FunctionalStateMachine.Core.Tests/FunctionalStateMachine.Core.Tests.csproj @@ -20,6 +20,9 @@ + 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)