Skip to content

Commit 6d782e0

Browse files
committed
Added static analysis doc. Skip Analysis option added. Unused trigger detected.
1 parent b420338 commit 6d782e0

File tree

7 files changed

+543
-17
lines changed

7 files changed

+543
-17
lines changed

FunctionalStateMachine.Core.Tests/StateMachineAnalysisTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,25 @@ public void Validate_AllowsComplexReachableStateMachine()
231231
Assert.NotNull(machine);
232232
}
233233

234+
[Fact]
235+
public void Validate_WarnsOnUnusedTriggers()
236+
{
237+
// State machine that doesn't use all defined trigger types
238+
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
239+
.StartWith(State.A)
240+
.For(State.A)
241+
.On<Trigger.T1>() // T1 is used
242+
.TransitionTo(State.B)
243+
.For(State.B)
244+
.On<Trigger.T1>()
245+
.TransitionTo(State.A)
246+
// T2 and T3 are never used
247+
.Build();
248+
249+
// Should not throw, but warnings should be logged
250+
Assert.NotNull(machine);
251+
}
252+
234253
private enum State
235254
{
236255
A,
@@ -242,6 +261,8 @@ private enum State
242261
private abstract record Trigger
243262
{
244263
public sealed record T1 : Trigger;
264+
public sealed record T2 : Trigger; // Unused trigger
265+
public sealed record T3 : Trigger; // Unused trigger
245266
}
246267

247268
private sealed record Data(int Value);

FunctionalStateMachine.Core/StateMachine.cs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ private bool HandleUnhandled(
201201
return false;
202202
}
203203

204-
internal void Validate()
204+
internal void Validate(bool skipAnalysis = false)
205205
{
206206
foreach (var definition in _states.Values)
207207
{
@@ -293,24 +293,27 @@ internal void Validate()
293293
ResolveInitialLeaf(_initialState!);
294294
}
295295

296-
// Run static analysis
297-
var analysis = StateMachineAnalyzer<TState, TTrigger, TData, TCommand>.Analyze(_states, _initialState!);
298-
299-
// Errors are treated as validation failures
300-
if (!analysis.IsValid)
296+
// Run static analysis (unless skipped)
297+
if (!skipAnalysis)
301298
{
302-
throw new InvalidOperationException(
303-
"State machine validation detected errors:\n" +
304-
string.Join("\n", analysis.Errors.Select(e => $" - {e}")));
305-
}
299+
var analysis = StateMachineAnalyzer<TState, TTrigger, TData, TCommand>.Analyze(_states, _initialState!);
300+
301+
// Errors are treated as validation failures
302+
if (!analysis.IsValid)
303+
{
304+
throw new InvalidOperationException(
305+
"State machine validation detected errors:\n" +
306+
string.Join("\n", analysis.Errors.Select(e => $" - {e}")));
307+
}
306308

307-
// Warnings are logged/reported (we could use ILogger here if needed)
308-
if (analysis.Warnings.Count > 0)
309-
{
310-
System.Diagnostics.Debug.WriteLine("State machine analysis detected warnings:");
311-
foreach (var warning in analysis.Warnings)
309+
// Warnings are logged/reported (we could use ILogger here if needed)
310+
if (analysis.Warnings.Count > 0)
312311
{
313-
System.Diagnostics.Debug.WriteLine($" - {warning}");
312+
System.Diagnostics.Debug.WriteLine("State machine analysis detected warnings:");
313+
foreach (var warning in analysis.Warnings)
314+
{
315+
System.Diagnostics.Debug.WriteLine($" - {warning}");
316+
}
314317
}
315318
}
316319
}

FunctionalStateMachine.Core/StateMachineAnalysis.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ public static AnalysisResult Analyze(
4747
// Analyze dead-end states
4848
AnalyzeDeadEndStates(states, initialState, result);
4949

50+
// Analyze unused triggers
51+
AnalyzeUnusedTriggers(states, result);
52+
5053
return result;
5154
}
5255

@@ -277,6 +280,89 @@ private static void AnalyzeDeadEndStates(
277280
}
278281
}
279282

283+
/// <summary>
284+
/// Detect trigger types that are defined but never used in any transition.
285+
/// </summary>
286+
private static void AnalyzeUnusedTriggers(
287+
IReadOnlyDictionary<TState, StateMachine<TState, TTrigger, TData, TCommand>.StateDefinition> states,
288+
AnalysisResult result)
289+
{
290+
// Collect all used trigger types
291+
var usedTriggers = new HashSet<object>();
292+
293+
foreach (var definition in states.Values)
294+
{
295+
foreach (var triggerKey in definition.Transitions.Keys)
296+
{
297+
usedTriggers.Add(triggerKey);
298+
}
299+
}
300+
301+
// Get all possible trigger types from the TTrigger type
302+
var triggerType = typeof(TTrigger);
303+
304+
// If TTrigger is a sealed record hierarchy, find all derived types
305+
var allTriggerTypes = GetAllTriggerTypes(triggerType);
306+
307+
// Find unused trigger types
308+
foreach (var possibleTrigger in allTriggerTypes)
309+
{
310+
// Check if this trigger type is used
311+
bool isUsed = usedTriggers.Any(t =>
312+
{
313+
// Handle both type-based and value-based triggers
314+
if (t is Type)
315+
return (Type)t == possibleTrigger;
316+
return t.GetType() == possibleTrigger;
317+
});
318+
319+
if (!isUsed)
320+
{
321+
result.AddWarning(
322+
$"Trigger type '{possibleTrigger.Name}' is defined but never used in any transition. " +
323+
"Consider removing it or adding transitions that use it.");
324+
}
325+
}
326+
}
327+
328+
private static HashSet<Type> GetAllTriggerTypes(Type triggerType)
329+
{
330+
var types = new HashSet<Type>();
331+
332+
// Add the base trigger type if it's abstract/record
333+
if (triggerType.IsAbstract || IsRecordType(triggerType))
334+
{
335+
// Find all derived types in the same assembly
336+
var assembly = triggerType.Assembly;
337+
foreach (var type in assembly.GetTypes())
338+
{
339+
// Check if it's a record/sealed record deriving from the trigger type
340+
if (type != triggerType &&
341+
triggerType.IsAssignableFrom(type) &&
342+
!type.IsAbstract &&
343+
IsRecordType(type))
344+
{
345+
types.Add(type);
346+
}
347+
}
348+
}
349+
350+
// Always add the trigger type itself as a fallback
351+
if (types.Count == 0)
352+
{
353+
types.Add(triggerType);
354+
}
355+
356+
return types;
357+
}
358+
359+
private static bool IsRecordType(Type type)
360+
{
361+
// Records are detected by checking for the generated 'EqualityContract' property
362+
return type.GetProperty("EqualityContract",
363+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) != null;
364+
}
365+
280366
private static string GetTriggerTypeName(object triggerKey)
281367
{
282368
// triggerKey is typically the trigger type or trigger value

FunctionalStateMachine.Core/StateMachineBuilder.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ public sealed class StateMachineBuilder<TState, TTrigger, TData, TCommand>
55
where TTrigger : notnull
66
{
77
private readonly StateMachine<TState, TTrigger, TData, TCommand> _machine = new();
8+
private bool _skipAnalysis = false;
89

910
public StateMachineBuilder<TState, TTrigger, TData, TCommand> StartWith(TState state)
1011
{
@@ -24,9 +25,18 @@ public StateConfiguration For(TState state)
2425
return new StateConfiguration(this, _machine.For(state));
2526
}
2627

28+
/// <summary>
29+
/// Skip static analysis when building. Use with caution - analysis catches real configuration errors.
30+
/// </summary>
31+
public StateMachineBuilder<TState, TTrigger, TData, TCommand> SkipAnalysis()
32+
{
33+
_skipAnalysis = true;
34+
return this;
35+
}
36+
2737
public StateMachine<TState, TTrigger, TData, TCommand> Build()
2838
{
29-
_machine.Validate();
39+
_machine.Validate(skipAnalysis: _skipAnalysis);
3040
return _machine;
3141
}
3242

FunctionalStateMachine.sln

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{919D74CB-8078-40
2828
docs\no-data.md = docs\no-data.md
2929
docs\state-data.md = docs\state-data.md
3030
docs\packages.md = docs\packages.md
31+
docs\conditional-steps.md = docs\conditional-steps.md
32+
docs\immediate-transitions.md = docs\immediate-transitions.md
33+
docs\static-analysis.md = docs\static-analysis.md
3134
EndProjectSection
3235
EndProject
3336
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionalStateMachine.CommandRunner", "FunctionalStateMachine.CommandRunner\FunctionalStateMachine.CommandRunner.csproj", "{A0A6A44F-CD8C-49F6-B659-5BA7B77A2BB4}"

docs/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ Welcome to the Functional State Machine docs. Each guide introduces a feature, w
1818
- Hierarchical states: `hierarchical-states.md`
1919
- No-data builder: `no-data.md`
2020

21+
## Validation and analysis
22+
23+
- Static analysis and configuration validation: `static-analysis.md`
24+
2125
## Tooling and integrations
2226

2327
- Mermaid diagram generation: `diagrams.md`

0 commit comments

Comments
 (0)