diff --git a/.autover/autover.json b/.autover/autover.json
index 02f2ad0db..88c3c1795 100644
--- a/.autover/autover.json
+++ b/.autover/autover.json
@@ -49,7 +49,10 @@
},
{
"Name": "Amazon.Lambda.DurableExecution",
- "Path": "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj",
+ "Paths": [
+ "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj",
+ "Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj"
+ ],
"PrereleaseLabel": "preview"
},
{
diff --git a/.autover/changes/durable-execution-analyzers.json b/.autover/changes/durable-execution-analyzers.json
new file mode 100644
index 000000000..4689a8839
--- /dev/null
+++ b/.autover/changes/durable-execution-analyzers.json
@@ -0,0 +1,11 @@
+{
+ "Projects": [
+ {
+ "Name": "Amazon.Lambda.DurableExecution",
+ "Type": "Minor",
+ "ChangelogMessages": [
+ "Add Roslyn analyzers (DE001-DE004) that catch common durable-execution authoring mistakes at build time, bundled in the package so they activate automatically for consumers. DE001 (Warning) flags non-deterministic APIs (DateTime.Now, Guid.NewGuid(), Random, Stopwatch, Environment.TickCount, crypto RNG) used in workflow code outside a step. DE002 (Warning) flags a durable operation invoked inside a step body via the captured outer IDurableContext. DE003 (Warning) flags mutation of a captured outer-scope variable inside a durable-operation delegate. DE004 (Info) suggests ParallelAsync/MapAsync over Task.WhenAll/Task.WhenAny for durable tasks. DE001 and DE004 include code fixes. Preview."
+ ]
+ }
+ ]
+}
diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln
index 1b2bedcd5..9f66a0031 100644
--- a/Libraries/Libraries.sln
+++ b/Libraries/Libraries.sln
@@ -165,6 +165,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecut
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnnotationsClassLibraryFunction", "test\Amazon.Lambda.DurableExecution.IntegrationTests\TestFunctions\AnnotationsClassLibraryFunction\AnnotationsClassLibraryFunction.csproj", "{D55E2D57-8374-4573-999B-6E64E109C25F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers", "src\Amazon.Lambda.DurableExecution.Analyzers\Amazon.Lambda.DurableExecution.Analyzers.csproj", "{6702F1BE-3A11-4DFB-93C3-50065789D814}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers.Tests", "test\Amazon.Lambda.DurableExecution.Analyzers.Tests\Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj", "{592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1039,6 +1043,30 @@ Global
{D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x64.Build.0 = Release|Any CPU
{D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.ActiveCfg = Release|Any CPU
{D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.Build.0 = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.Build.0 = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.Build.0 = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.ActiveCfg = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.Build.0 = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.ActiveCfg = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.Build.0 = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.Build.0 = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.Build.0 = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.ActiveCfg = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.Build.0 = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.ActiveCfg = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1120,6 +1148,8 @@ Global
{CA132CAB-FF4F-4312-B3A3-66DE9D360F27} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
{16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
{D55E2D57-8374-4573-999B-6E64E109C25F} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
+ {6702F1BE-3A11-4DFB-93C3-50065789D814} = {AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12}
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj
new file mode 100644
index 000000000..f64018a6e
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj
@@ -0,0 +1,48 @@
+
+
+
+
+ netstandard2.0
+ latest
+ enable
+ disable
+
+ Roslyn analyzers and code fixes for Amazon.Lambda.DurableExecution — catches durable-execution determinism and authoring mistakes (DE001-DE004) at build time.
+ Amazon.Lambda.DurableExecution.Analyzers
+ Amazon.Lambda.DurableExecution.Analyzers
+ AWS;Amazon;Lambda;Durable;Workflow;Analyzer;Roslyn
+
+
+ false
+ false
+ true
+
+
+ ..\..\..\buildtools\public.snk
+ true
+
+ true
+ true
+
+
+ $(NoWarn);RS1032;RS1033
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md
new file mode 100644
index 000000000..f50bb1fe2
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md
@@ -0,0 +1,2 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md
new file mode 100644
index 000000000..ceae365d0
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,11 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|----------------------------------------------------------------------
+DE001 | AWSLambdaDurableExecution | Warning | Non-deterministic call outside a step. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de001
+DE002 | AWSLambdaDurableExecution | Warning | Nested durable operation inside a step body. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de002
+DE003 | AWSLambdaDurableExecution | Warning | Mutable variable captured and modified inside a durable operation. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de003
+DE004 | AWSLambdaDurableExecution | Info | Task.WhenAll/WhenAny over durable tasks; prefer ParallelAsync/MapAsync. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de004
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs
new file mode 100644
index 000000000..448fea326
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs
@@ -0,0 +1,82 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using Microsoft.CodeAnalysis;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Diagnostic descriptors for the durable-execution analyzers. These mirror the
+ /// JavaScript SDK's ESLint plugin rules (no-non-deterministic-outside-step,
+ /// no-nested-durable-operations, no-closure-in-durable-operations) and add a
+ /// .NET-specific rule (DE004) for the Task.WhenAll/WhenAny pattern.
+ ///
+ public static class DurableDiagnostics
+ {
+ internal const string Category = "AWSLambdaDurableExecution";
+
+ private const string HelpRoot =
+ "https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md";
+
+ ///
+ /// DE001 — a non-deterministic API (DateTime.Now, Guid.NewGuid, Random, …) is used in
+ /// workflow code outside a step. On replay the workflow re-runs from the top, so the value
+ /// differs between the original run and replays, corrupting checkpoint-derived state.
+ ///
+ public static readonly DiagnosticDescriptor NonDeterministicCallOutsideStep = new DiagnosticDescriptor(
+ id: "DE001",
+ title: "Non-deterministic call outside a step",
+ messageFormat: "Non-deterministic operation '{0}' is used in workflow code outside a step. Move it inside a step (e.g. context.StepAsync(...)) so its result is checkpointed and replays consistently",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Durable workflow code re-executes from the top on every invocation. Values from non-deterministic APIs differ between the original execution and replays unless they are captured inside a step.",
+ helpLinkUri: HelpRoot + "#de001");
+
+ ///
+ /// DE002 — a durable operation is invoked inside a step body by capturing the outer
+ /// IDurableContext. Step bodies must contain only plain, deterministic code;
+ /// nesting durable operations requires RunInChildContextAsync.
+ ///
+ public static readonly DiagnosticDescriptor NestedDurableOperationInsideStep = new DiagnosticDescriptor(
+ id: "DE002",
+ title: "Nested durable operation inside a step body",
+ messageFormat: "Durable operation '{0}' is called on the outer durable context '{1}' inside a {2} body. Step bodies must contain only plain, deterministic code; nest durable operations with context.RunInChildContextAsync(...) instead",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "A step body is replayed verbatim on every retry. Calling another durable operation from inside it produces unpredictable behavior; use RunInChildContextAsync to group durable operations.",
+ helpLinkUri: HelpRoot + "#de002");
+
+ ///
+ /// DE003 — a variable captured from an outer scope is mutated inside a durable-operation
+ /// delegate. On replay the operation returns its cached result without re-executing the body,
+ /// so the write never happens and the captured variable holds stale state.
+ ///
+ public static readonly DiagnosticDescriptor MutableCaptureInDurableOperation = new DiagnosticDescriptor(
+ id: "DE003",
+ title: "Mutable variable captured and modified inside a durable operation",
+ messageFormat: "Variable '{0}' is captured from an outer scope and modified inside a durable operation. On replay the operation returns its cached result without re-executing the body, so this write is lost and '{0}' becomes stale. Return the value from the operation and assign it (e.g. {0} = await context.StepAsync(...)) instead",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Reading a captured variable inside a durable operation is safe; mutating it is not, because the body is skipped on replay.",
+ helpLinkUri: HelpRoot + "#de003");
+
+ ///
+ /// DE004 — Task.WhenAll/Task.WhenAny is called with tasks produced by durable
+ /// operations. This is not incorrect (operation IDs are allocated deterministically), but it
+ /// bypasses completion policies, concurrency limits, branch naming, and IBatchResult output.
+ /// Advisory (Info) only.
+ ///
+ public static readonly DiagnosticDescriptor DurableTaskInTaskCombinator = new DiagnosticDescriptor(
+ id: "DE004",
+ title: "Prefer ParallelAsync/MapAsync over Task.WhenAll/WhenAny for durable tasks",
+ messageFormat: "'{0}' over durable tasks bypasses completion policies, concurrency limits, branch naming, and IBatchResult output. Use context.ParallelAsync (or MapAsync) so concurrent durable operations get framework coordination and complete execution traces",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: "Task.WhenAll/WhenAny work correctly with durable tasks, but ParallelAsync/MapAsync are preferred for completion policies, concurrency control, and observability.",
+ helpLinkUri: HelpRoot + "#de004");
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs
new file mode 100644
index 000000000..40b05ec51
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs
@@ -0,0 +1,344 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// How a delegate passed to a durable operation executes, which determines what the
+ /// analyzers allow inside its body.
+ ///
+ internal enum DurableDelegateRole
+ {
+ /// Not a delegate accepted by a durable operation.
+ None,
+
+ ///
+ /// The delegate body runs inside a checkpointed step (StepAsync func,
+ /// WaitForCallbackAsync submitter, WaitForConditionAsync check). Non-deterministic code is
+ /// allowed here because the result is checkpointed; nested durable operations are not.
+ ///
+ StepWrapped,
+
+ ///
+ /// The delegate body runs as a child sub-workflow (RunInChildContextAsync func, ParallelAsync
+ /// branch, MapAsync func). It is still workflow code (must be deterministic), but nested
+ /// durable operations are allowed.
+ ///
+ ChildContext,
+ }
+
+ ///
+ /// Per-compilation cache of the durable-execution and BCL symbols the analyzers match against.
+ /// Resolved once in a compilation-start action; if IDurableContext is not present
+ /// the compilation does not use durable execution and the analyzers register nothing.
+ ///
+ internal sealed class DurableKnownSymbols
+ {
+ internal const string IDurableContextMetadataName = "Amazon.Lambda.DurableExecution.IDurableContext";
+
+ /// The names of the durable operations declared on IDurableContext.
+ private static readonly ImmutableHashSet DurableOperationNames = ImmutableHashSet.Create(
+ "StepAsync",
+ "WaitAsync",
+ "RunInChildContextAsync",
+ "CreateCallbackAsync",
+ "WaitForCallbackAsync",
+ "InvokeAsync",
+ "WaitForConditionAsync",
+ "ParallelAsync",
+ "MapAsync");
+
+ internal INamedTypeSymbol DurableContext { get; }
+
+ // BCL types backing the DE001 non-determinism catalog. Any may be null on a given target framework.
+ private readonly INamedTypeSymbol? _dateTime;
+ private readonly INamedTypeSymbol? _dateTimeOffset;
+ private readonly INamedTypeSymbol? _guid;
+ private readonly INamedTypeSymbol? _random;
+ private readonly INamedTypeSymbol? _stopwatch;
+ private readonly INamedTypeSymbol? _environment;
+ private readonly INamedTypeSymbol? _path;
+ private readonly INamedTypeSymbol? _randomNumberGenerator;
+ private readonly INamedTypeSymbol? _rngCryptoServiceProvider;
+ private readonly INamedTypeSymbol? _task;
+ private readonly INamedTypeSymbol? _taskOfT;
+
+ private DurableKnownSymbols(Compilation compilation, INamedTypeSymbol durableContext)
+ {
+ DurableContext = durableContext;
+ _dateTime = compilation.GetTypeByMetadataName("System.DateTime");
+ _dateTimeOffset = compilation.GetTypeByMetadataName("System.DateTimeOffset");
+ _guid = compilation.GetTypeByMetadataName("System.Guid");
+ _random = compilation.GetTypeByMetadataName("System.Random");
+ _stopwatch = compilation.GetTypeByMetadataName("System.Diagnostics.Stopwatch");
+ _environment = compilation.GetTypeByMetadataName("System.Environment");
+ _path = compilation.GetTypeByMetadataName("System.IO.Path");
+ _randomNumberGenerator = compilation.GetTypeByMetadataName("System.Security.Cryptography.RandomNumberGenerator");
+ _rngCryptoServiceProvider = compilation.GetTypeByMetadataName("System.Security.Cryptography.RNGCryptoServiceProvider");
+ _task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task");
+ _taskOfT = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1");
+ }
+
+ ///
+ /// Resolves the durable symbols. Returns null when the compilation does not reference
+ /// Amazon.Lambda.DurableExecution, so callers register no per-node work.
+ ///
+ internal static DurableKnownSymbols? TryCreate(Compilation compilation)
+ {
+ var durableContext = compilation.GetTypeByMetadataName(IDurableContextMetadataName);
+ return durableContext is null ? null : new DurableKnownSymbols(compilation, durableContext);
+ }
+
+ ///
+ /// True if is IDurableContext or implements it (covers the
+ /// concrete DurableContext and any user implementation).
+ ///
+ internal bool IsDurableContextType(ITypeSymbol? type)
+ {
+ if (type is null)
+ {
+ return false;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, DurableContext))
+ {
+ return true;
+ }
+
+ foreach (var iface in type.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, DurableContext))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// True if is one of the durable operations on a durable context.
+ /// Matched by symbol (containing type is/implements IDurableContext) plus an explicit
+ /// name allowlist, so property getters and ConfigureLogger are excluded, and an
+ /// unrelated obj.StepAsync() on some other type is never matched.
+ ///
+ internal bool IsDurableOperation(IMethodSymbol? method, out string operationName, out DurableDelegateRole role)
+ {
+ operationName = string.Empty;
+ role = DurableDelegateRole.None;
+
+ if (method is null)
+ {
+ return false;
+ }
+
+ var name = method.OriginalDefinition.Name;
+ if (!DurableOperationNames.Contains(name))
+ {
+ return false;
+ }
+
+ if (!IsDurableContextType(method.OriginalDefinition.ContainingType))
+ {
+ return false;
+ }
+
+ operationName = name;
+ role = RoleFor(name);
+ return true;
+ }
+
+ private static DurableDelegateRole RoleFor(string operationName)
+ {
+ switch (operationName)
+ {
+ case "StepAsync":
+ case "WaitForCallbackAsync":
+ case "WaitForConditionAsync":
+ return DurableDelegateRole.StepWrapped;
+ case "RunInChildContextAsync":
+ case "ParallelAsync":
+ case "MapAsync":
+ return DurableDelegateRole.ChildContext;
+ default:
+ return DurableDelegateRole.None;
+ }
+ }
+
+ /// True if is Task.WhenAll or Task.WhenAny.
+ internal bool IsTaskCombinator(IMethodSymbol? method, out string friendlyName)
+ {
+ friendlyName = string.Empty;
+ if (method is null || _task is null)
+ {
+ return false;
+ }
+
+ var def = method.OriginalDefinition;
+ if (!SymbolEqualityComparer.Default.Equals(def.ContainingType, _task))
+ {
+ return false;
+ }
+
+ if (def.Name == "WhenAll")
+ {
+ friendlyName = "Task.WhenAll";
+ return true;
+ }
+
+ if (def.Name == "WhenAny")
+ {
+ friendlyName = "Task.WhenAny";
+ return true;
+ }
+
+ return false;
+ }
+
+ /// True if the type is Task or Task<T> (a candidate durable task).
+ internal bool IsTaskType(ITypeSymbol? type)
+ {
+ if (type is null)
+ {
+ return false;
+ }
+
+ var def = type.OriginalDefinition;
+ return SymbolEqualityComparer.Default.Equals(def, _task)
+ || SymbolEqualityComparer.Default.Equals(def, _taskOfT);
+ }
+
+ ///
+ /// Returns the friendly name (e.g. "DateTime.Now") if the operation reads/calls/creates
+ /// a non-deterministic API from the DE001 catalog; otherwise null.
+ ///
+ internal string? TryGetNonDeterministicApi(IOperation operation)
+ {
+ switch (operation)
+ {
+ case IPropertyReferenceOperation pr:
+ return MatchProperty(pr.Property);
+ case IInvocationOperation inv:
+ return MatchMethod(inv.TargetMethod);
+ case IObjectCreationOperation oc:
+ return MatchObjectCreation(oc);
+ default:
+ return null;
+ }
+ }
+
+ private string? MatchProperty(IPropertySymbol property)
+ {
+ var owner = property.ContainingType?.OriginalDefinition;
+ var name = property.Name;
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _dateTime)
+ && (name == "Now" || name == "UtcNow" || name == "Today"))
+ {
+ return "DateTime." + name;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _dateTimeOffset)
+ && (name == "Now" || name == "UtcNow"))
+ {
+ return "DateTimeOffset." + name;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _environment)
+ && (name == "TickCount" || name == "TickCount64"))
+ {
+ return "Environment." + name;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch)
+ && (name == "Elapsed" || name == "ElapsedMilliseconds" || name == "ElapsedTicks"))
+ {
+ return "Stopwatch." + name;
+ }
+
+ // Random.Shared is seeded non-deterministically. (A user-seeded `new Random(42)` is
+ // deterministic, so we flag the seedless ctor / Random.Shared, not the .Next() call.)
+ if (SymbolEqualityComparer.Default.Equals(owner, _random) && name == "Shared")
+ {
+ return "Random.Shared";
+ }
+
+ return null;
+ }
+
+ private string? MatchMethod(IMethodSymbol method)
+ {
+ var owner = method.ContainingType?.OriginalDefinition;
+ var name = method.Name;
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _guid) && name == "NewGuid")
+ {
+ return "Guid.NewGuid()";
+ }
+
+ // Note: Random instance methods (.Next() etc.) are intentionally NOT flagged here — a
+ // user-seeded `new Random(42)` is deterministic. Non-determinism is introduced by the
+ // seedless `new Random()` ctor (MatchObjectCreation) and `Random.Shared` (MatchProperty).
+ if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch)
+ && (name == "GetTimestamp" || name == "StartNew"))
+ {
+ return "Stopwatch." + name + "()";
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _randomNumberGenerator)
+ && (name == "GetBytes" || name == "GetInt32" || name == "Fill"
+ || name == "GetString" || name == "GetHexString"))
+ {
+ return "RandomNumberGenerator." + name + "()";
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _path)
+ && (name == "GetTempFileName" || name == "GetRandomFileName"))
+ {
+ return "Path." + name + "()";
+ }
+
+ return null;
+ }
+
+ private string? MatchObjectCreation(IObjectCreationOperation oc)
+ {
+ var type = oc.Constructor?.ContainingType?.OriginalDefinition;
+
+ // Only the seedless Random ctor is non-deterministic; new Random(seed) is fine.
+ if (SymbolEqualityComparer.Default.Equals(type, _random) && oc.Arguments.Length == 0)
+ {
+ return "new Random()";
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(type, _rngCryptoServiceProvider))
+ {
+ return "new RNGCryptoServiceProvider()";
+ }
+
+ return null;
+ }
+
+ ///
+ /// True if contains an IDurableContext-typed parameter,
+ /// which marks a method/local-function/lambda as durable workflow code.
+ ///
+ internal bool HasDurableContextParameter(IEnumerable parameters)
+ {
+ foreach (var p in parameters)
+ {
+ if (IsDurableContextType(p.Type))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs
new file mode 100644
index 000000000..5ec1846df
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs
@@ -0,0 +1,201 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Shared helpers for reasoning about where an operation sits relative to durable-operation
+ /// delegates. Used by DE001 (is this non-deterministic call inside a step?) and DE002 (is this
+ /// durable call inside a step body?).
+ ///
+ internal static class DurableScope
+ {
+ ///
+ /// Describes the nearest enclosing durable-operation delegate of an operation, if any.
+ ///
+ internal readonly struct EnclosingDelegate
+ {
+ internal EnclosingDelegate(DurableDelegateRole role, string operationName, IAnonymousFunctionOperation function)
+ {
+ Role = role;
+ OperationName = operationName;
+ Function = function;
+ }
+
+ internal DurableDelegateRole Role { get; }
+
+ internal string OperationName { get; }
+
+ internal IAnonymousFunctionOperation Function { get; }
+
+ internal bool Found => Role != DurableDelegateRole.None;
+ }
+
+ ///
+ /// Walks up the operation tree from and returns the nearest
+ /// enclosing lambda that is an argument to a durable operation, classified by its role.
+ /// Non-durable lambdas (e.g. a Select projection or Task.Run) are skipped so a
+ /// non-deterministic call nested in such a lambda is still attributed to the durable step that
+ /// contains it.
+ ///
+ internal static EnclosingDelegate FindNearestDurableDelegate(IOperation operation, DurableKnownSymbols symbols)
+ {
+ for (var current = operation.Parent; current is not null; current = current.Parent)
+ {
+ if (current is IAnonymousFunctionOperation lambda
+ && TryClassifyDelegate(lambda, symbols, out var role, out var opName))
+ {
+ return new EnclosingDelegate(role, opName, lambda);
+ }
+ }
+
+ return default;
+ }
+
+ ///
+ /// True if any enclosing scope (method, local function, or lambda) of
+ /// is durable workflow code — i.e. declares an
+ /// IDurableContext parameter. This is the single shared scoping primitive: it covers
+ /// the [DurableExecution] annotation path, the hand-wired
+ /// DurableFunction.WrapAsync(async (input, ctx) => …) lambda, and child/parallel/map
+ /// branch delegates.
+ ///
+ internal static bool IsInWorkflowCode(IOperation operation, DurableKnownSymbols symbols)
+ {
+ for (var current = operation.Parent; current is not null; current = current.Parent)
+ {
+ switch (current)
+ {
+ case IAnonymousFunctionOperation lambda
+ when symbols.HasDurableContextParameter(lambda.Symbol.Parameters):
+ return true;
+ case ILocalFunctionOperation local
+ when symbols.HasDurableContextParameter(local.Symbol.Parameters):
+ return true;
+ case IMethodBodyOperation:
+ case IBlockOperation { Parent: null }:
+ break;
+ }
+ }
+
+ // The enclosing method symbol (top-level body, not reachable as an IOperation parent).
+ var enclosing = operation.SemanticModel?.GetEnclosingSymbol(operation.Syntax.SpanStart) as IMethodSymbol;
+ while (enclosing is not null)
+ {
+ if (symbols.HasDurableContextParameter(enclosing.Parameters))
+ {
+ return true;
+ }
+
+ enclosing = enclosing.ContainingSymbol as IMethodSymbol;
+ }
+
+ return false;
+ }
+
+ ///
+ /// If is passed as an argument to a durable operation, reports which
+ /// operation and the delegate's role. Maps the lambda's argument position to the corresponding
+ /// parameter so the WaitForCallbackAsync submitter / WaitForConditionAsync check (which are
+ /// step-wrapped) and the ParallelAsync/MapAsync branches (child context) are classified
+ /// correctly — even though they are not the operation's "first" delegate.
+ ///
+ internal static bool TryClassifyDelegate(
+ IAnonymousFunctionOperation lambda,
+ DurableKnownSymbols symbols,
+ out DurableDelegateRole role,
+ out string operationName)
+ {
+ role = DurableDelegateRole.None;
+ operationName = string.Empty;
+
+ // The lambda may be a direct argument (StepAsync(lambda)), wrapped in a delegate
+ // conversion, or nested inside the array/collection that builds the branches list of
+ // ParallelAsync ([ (c, ct) => …, (c, ct) => … ]). Walk up through those expression
+ // wrappers to the enclosing argument, but stop at any statement, block, or another
+ // anonymous function so we never attribute the lambda to a non-enclosing invocation.
+ // This avoids naming ICollectionExpressionOperation, which is not in all Roslyn versions.
+ IOperation? argument = lambda.Parent;
+ while (argument is not null
+ && argument is not IArgumentOperation
+ && argument is not IAnonymousFunctionOperation
+ && argument is not IBlockOperation
+ && argument is not IExpressionStatementOperation)
+ {
+ argument = argument.Parent;
+ }
+
+ if (argument is not IArgumentOperation arg || arg.Parent is not IInvocationOperation invocation)
+ {
+ return false;
+ }
+
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out operationName, out role))
+ {
+ role = DurableDelegateRole.None;
+ operationName = string.Empty;
+ return false;
+ }
+
+ return role != DurableDelegateRole.None;
+ }
+
+ ///
+ /// Collects the symbols (parameters and locals) declared within ,
+ /// including nested lambdas/local functions, so DE003 can tell a captured outer variable from a
+ /// delegate-local one.
+ ///
+ internal static HashSet CollectDeclaredSymbols(IAnonymousFunctionOperation function)
+ {
+ // The comparer IS supplied; this suppresses a known RS1024 false positive in the 4.0.1
+ // analyzer pack that flags `new HashSet(SymbolEqualityComparer.Default)`.
+#pragma warning disable RS1024 // Compare symbols correctly
+ var declared = new HashSet(SymbolEqualityComparer.Default);
+#pragma warning restore RS1024
+
+ foreach (var p in function.Symbol.Parameters)
+ {
+ declared.Add(p);
+ }
+
+ CollectDescendantDeclarations(function.Body, declared);
+ return declared;
+ }
+
+ private static void CollectDescendantDeclarations(IOperation? operation, HashSet declared)
+ {
+ if (operation is null)
+ {
+ return;
+ }
+
+ foreach (var child in operation.Descendants())
+ {
+ switch (child)
+ {
+ case IVariableDeclaratorOperation decl:
+ declared.Add(decl.Symbol);
+ break;
+ case IAnonymousFunctionOperation nested:
+ foreach (var p in nested.Symbol.Parameters)
+ {
+ declared.Add(p);
+ }
+
+ break;
+ case ILocalFunctionOperation localFn:
+ foreach (var p in localFn.Symbol.Parameters)
+ {
+ declared.Add(p);
+ }
+
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs
new file mode 100644
index 000000000..1b232c4b9
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs
@@ -0,0 +1,200 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE004 — flags Task.WhenAll/Task.WhenAny called with tasks produced by durable
+ /// operations. This is advisory (Info): the combinators work correctly with durable tasks because
+ /// operation IDs are allocated deterministically, but they bypass completion policies, concurrency
+ /// limits, branch naming, and structured IBatchResult output, so ParallelAsync /
+ /// MapAsync are preferred.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class DurableTaskCombinatorAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return;
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeInvocation(ctx, symbols),
+ OperationKind.Invocation);
+ });
+ }
+
+ private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var invocation = (IInvocationOperation)context.Operation;
+
+ if (!symbols.IsTaskCombinator(invocation.TargetMethod, out var friendlyName))
+ {
+ return;
+ }
+
+ // Gather the task expressions passed in and report if any is produced by a durable op.
+ foreach (var argument in invocation.Arguments)
+ {
+ if (ArgumentContainsDurableTask(argument.Value, symbols))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.DurableTaskInTaskCombinator,
+ invocation.Syntax.GetLocation(),
+ friendlyName));
+ return;
+ }
+ }
+ }
+
+ ///
+ /// True if the argument value contains (directly, or via the local it references) at least one
+ /// task produced by a durable-context operation.
+ ///
+ private static bool ArgumentContainsDurableTask(IOperation value, DurableKnownSymbols symbols)
+ {
+ // Strip params-array / collection conversions.
+ value = Unwrap(value);
+
+ // Inline: Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) or new[] { ... } or [ ... ].
+ foreach (var op in value.DescendantsAndSelf())
+ {
+ if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols))
+ {
+ return true;
+ }
+ }
+
+ // Indirect: Task[] tasks = ...; await Task.WhenAll(tasks). Follow a local to its
+ // initializer and to assignments/Add calls in the enclosing method body.
+ if (value is ILocalReferenceOperation localRef)
+ {
+ return LocalIsPopulatedWithDurableTasks(localRef, symbols);
+ }
+
+ return false;
+ }
+
+ private static IOperation Unwrap(IOperation op)
+ {
+ while (op is IConversionOperation conv)
+ {
+ op = conv.Operand;
+ }
+
+ return op;
+ }
+
+ private static bool IsDurableTaskProducer(IInvocationOperation invocation, DurableKnownSymbols symbols)
+ {
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _))
+ {
+ return false;
+ }
+
+ return invocation.Instance is not null && symbols.IsDurableContextType(invocation.Instance.Type);
+ }
+
+ ///
+ /// Bounded scan: walks the enclosing method/lambda body for the declaration of the referenced
+ /// local and any assignments or List.Add calls that store a durable task into it.
+ ///
+ private static bool LocalIsPopulatedWithDurableTasks(ILocalReferenceOperation localRef, DurableKnownSymbols symbols)
+ {
+ var local = localRef.Local;
+
+ // Find the enclosing body operation that contains the WhenAll call.
+ IOperation root = localRef;
+ while (root.Parent is not null)
+ {
+ root = root.Parent;
+ }
+
+ foreach (var op in root.DescendantsAndSelf())
+ {
+ switch (op)
+ {
+ case IVariableDeclaratorOperation decl
+ when SymbolEqualityComparer.Default.Equals(decl.Symbol, local)
+ && decl.Initializer is not null:
+ if (ContainsDurableTaskProducer(decl.Initializer.Value, symbols))
+ {
+ return true;
+ }
+
+ break;
+
+ case ISimpleAssignmentOperation assign
+ when TargetsLocal(assign.Target, local):
+ if (ContainsDurableTaskProducer(assign.Value, symbols))
+ {
+ return true;
+ }
+
+ break;
+
+ case IInvocationOperation addCall
+ when addCall.TargetMethod.Name == "Add"
+ && addCall.Instance is ILocalReferenceOperation listRef
+ && SymbolEqualityComparer.Default.Equals(listRef.Local, local):
+ foreach (var addArg in addCall.Arguments)
+ {
+ if (ContainsDurableTaskProducer(addArg.Value, symbols))
+ {
+ return true;
+ }
+ }
+
+ break;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TargetsLocal(IOperation target, ILocalSymbol local)
+ {
+ // Direct local assignment, or element assignment tasks[i] = ...
+ switch (Unwrap(target))
+ {
+ case ILocalReferenceOperation l:
+ return SymbolEqualityComparer.Default.Equals(l.Local, local);
+ case IArrayElementReferenceOperation arr when arr.ArrayReference is ILocalReferenceOperation arrLocal:
+ return SymbolEqualityComparer.Default.Equals(arrLocal.Local, local);
+ default:
+ return false;
+ }
+ }
+
+ private static bool ContainsDurableTaskProducer(IOperation value, DurableKnownSymbols symbols)
+ {
+ foreach (var op in Unwrap(value).DescendantsAndSelf())
+ {
+ if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs
new file mode 100644
index 000000000..6bd52acce
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs
@@ -0,0 +1,233 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Formatting;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Code fix for DE004: rewrites await Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) into
+ /// await ctx.ParallelAsync(new[] { (c, ct) => c.StepAsync(a), (c, ct) => c.StepAsync(b) })
+ /// for the one provably-safe shape: an inline list of direct durable calls on a single shared
+ /// context whose aggregate result is discarded. Every other shape (result consumed, mixed task
+ /// types, a variable instead of an inline list, WhenAny) is left diagnostic-only.
+ ///
+ [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DurableTaskCombinatorCodeFixProvider))]
+ [Shared]
+ public sealed class DurableTaskCombinatorCodeFixProvider : CodeFixProvider
+ {
+ ///
+ public override ImmutableArray FixableDiagnosticIds { get; } =
+ ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator.Id);
+
+ /// Fix-all is disabled — each conversion changes durable-operation structure and is reviewed individually.
+ public override FixAllProvider? GetFixAllProvider() => null;
+
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ var diagnostic = context.Diagnostics[0];
+ var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
+ var invocation = node.FirstAncestorOrSelf();
+ if (invocation is null)
+ {
+ return;
+ }
+
+ // Only WhenAll (not WhenAny) and only when its result is discarded (the await is an
+ // expression statement, not consumed by an assignment / argument / return).
+ if (GetCombinatorName(invocation) != "WhenAll" || !ResultIsDiscarded(invocation))
+ {
+ return;
+ }
+
+ // All arguments must be inline durable calls of the SAME receiver identifier.
+ if (!TryGetHomogeneousDurableCalls(invocation, out var receiver, out var calls))
+ {
+ return;
+ }
+
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ if (semanticModel is null)
+ {
+ return;
+ }
+
+ // Determine the shared result type T (each durable call returns Task). Bail if the calls
+ // are not all the same T — that is not the provably-safe homogeneous shape.
+ var elementType = TryGetSharedResultType(calls, semanticModel, context.CancellationToken);
+ if (elementType is null)
+ {
+ return;
+ }
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: $"Convert to {receiver}.ParallelAsync(...)",
+ createChangedDocument: ct => ConvertAsync(context.Document, root, invocation, receiver, calls, elementType, ct),
+ equivalenceKey: "ConvertToParallelAsync"),
+ diagnostic);
+ }
+
+ private static string? TryGetSharedResultType(
+ List calls,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken)
+ {
+ string? shared = null;
+ foreach (var call in calls)
+ {
+ if (semanticModel.GetTypeInfo(call, cancellationToken).Type is not INamedTypeSymbol taskType
+ || taskType.TypeArguments.Length != 1)
+ {
+ return null; // Non-generic Task (void step) — not handled by this fix.
+ }
+
+ var display = taskType.TypeArguments[0].ToDisplayString();
+ if (shared is null)
+ {
+ shared = display;
+ }
+ else if (shared != display)
+ {
+ return null; // Heterogeneous result types — bail.
+ }
+ }
+
+ return shared;
+ }
+
+ private static string? GetCombinatorName(InvocationExpressionSyntax invocation)
+ {
+ return invocation.Expression switch
+ {
+ MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText,
+ _ => null,
+ };
+ }
+
+ private static bool ResultIsDiscarded(InvocationExpressionSyntax invocation)
+ {
+ // The WhenAll invocation is typically wrapped in an await; the await must stand alone.
+ SyntaxNode current = invocation;
+ if (current.Parent is AwaitExpressionSyntax awaitExpr)
+ {
+ current = awaitExpr;
+ }
+
+ return current.Parent is ExpressionStatementSyntax;
+ }
+
+ private static bool TryGetHomogeneousDurableCalls(
+ InvocationExpressionSyntax invocation,
+ out string receiver,
+ out List calls)
+ {
+ receiver = string.Empty;
+ calls = new List();
+
+ var arguments = invocation.ArgumentList.Arguments;
+ if (arguments.Count < 2)
+ {
+ return false; // Nothing to parallelize.
+ }
+
+ string? sharedReceiver = null;
+ foreach (var argument in arguments)
+ {
+ if (argument.Expression is not InvocationExpressionSyntax call
+ || call.Expression is not MemberAccessExpressionSyntax memberAccess
+ || memberAccess.Expression is not IdentifierNameSyntax receiverId)
+ {
+ return false; // Not a direct ctx.Method(...) call.
+ }
+
+ if (sharedReceiver is null)
+ {
+ sharedReceiver = receiverId.Identifier.ValueText;
+ }
+ else if (sharedReceiver != receiverId.Identifier.ValueText)
+ {
+ return false; // Mixed receivers — bail.
+ }
+
+ calls.Add(call);
+ }
+
+ receiver = sharedReceiver!;
+ return true;
+ }
+
+ private static Task ConvertAsync(
+ Document document,
+ SyntaxNode root,
+ InvocationExpressionSyntax whenAll,
+ string receiver,
+ List calls,
+ string elementType,
+ CancellationToken cancellationToken)
+ {
+ // Build one branch lambda per call: (c, ct) => c.StepAsync(...originalArgs...)
+ var branchLambdas = calls.Select(call =>
+ {
+ var memberAccess = (MemberAccessExpressionSyntax)call.Expression;
+ var rebound = call.WithExpression(
+ memberAccess.WithExpression(SyntaxFactory.IdentifierName("c")));
+
+ return (ExpressionSyntax)SyntaxFactory.ParenthesizedLambdaExpression(
+ SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[]
+ {
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("c")),
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("ct")),
+ })),
+ rebound);
+ }).ToArray();
+
+ // Explicitly-typed array — a lambda has no inferable type, so `new[] { … }` would not
+ // compile; emit `new Func>[] { … }`.
+ var arrayType = SyntaxFactory.ArrayType(
+ SyntaxFactory.ParseTypeName(
+ $"System.Func>"),
+ SyntaxFactory.SingletonList(
+ SyntaxFactory.ArrayRankSpecifier(
+ SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.OmittedArraySizeExpression()))));
+
+ var arrayLiteral = SyntaxFactory.ArrayCreationExpression(
+ arrayType,
+ SyntaxFactory.InitializerExpression(
+ SyntaxKind.ArrayInitializerExpression,
+ SyntaxFactory.SeparatedList(branchLambdas)));
+
+ var parallelCall = SyntaxFactory.InvocationExpression(
+ SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName(receiver),
+ SyntaxFactory.IdentifierName("ParallelAsync")),
+ SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(arrayLiteral))))
+ .WithTriviaFrom(whenAll)
+ .WithAdditionalAnnotations(Formatter.Annotation);
+
+ var newRoot = root.ReplaceNode(whenAll, parallelCall);
+ return Task.FromResult(document.WithSyntaxRoot(newRoot));
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs
new file mode 100644
index 000000000..b2ba45da0
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs
@@ -0,0 +1,157 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE003 — flags mutation of a variable captured from an outer scope inside a durable-operation
+ /// delegate (StepAsync, RunInChildContextAsync, WaitForConditionAsync, WaitForCallbackAsync, and
+ /// the ParallelAsync / MapAsync branches). On replay the delegate body is skipped and the cached
+ /// result returned, so the write never happens and the captured variable holds stale state.
+ /// Reading a captured variable is safe and not flagged.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class MutableCaptureAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.MutableCaptureInDurableOperation);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return;
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeInvocation(ctx, symbols),
+ OperationKind.Invocation);
+ });
+ }
+
+ private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var invocation = (IInvocationOperation)context.Operation;
+
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _))
+ {
+ return;
+ }
+
+ if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type))
+ {
+ return;
+ }
+
+ // Each delegate argument is analyzed independently. ParallelAsync takes a list of branch
+ // delegates, so a single invocation can carry several lambdas (nested in array/collection
+ // wrappers). We collect only the OUTERMOST lambdas (the actual step/branch delegates);
+ // a lambda nested inside a delegate body is covered by that delegate's recursive analysis,
+ // so collecting it again would double-report the same captured write.
+ foreach (var argument in invocation.Arguments)
+ {
+ foreach (var lambda in OutermostLambdas(argument))
+ {
+ AnalyzeDelegate(context, lambda);
+ }
+ }
+ }
+
+ private static IEnumerable OutermostLambdas(IOperation root)
+ {
+ foreach (var op in root.Descendants())
+ {
+ // Keep a lambda only if no other lambda sits between it and the argument root, so we
+ // return the branch/step delegates themselves, not lambdas nested inside their bodies.
+ if (op is IAnonymousFunctionOperation lambda && !HasEnclosingLambdaBelow(lambda, root))
+ {
+ yield return lambda;
+ }
+ }
+ }
+
+ private static bool HasEnclosingLambdaBelow(IOperation operation, IOperation root)
+ {
+ for (var parent = operation.Parent; parent is not null && !ReferenceEquals(parent, root); parent = parent.Parent)
+ {
+ if (parent is IAnonymousFunctionOperation)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void AnalyzeDelegate(OperationAnalysisContext context, IAnonymousFunctionOperation lambda)
+ {
+ var declaredInDelegate = DurableScope.CollectDeclaredSymbols(lambda);
+
+ foreach (var descendant in lambda.Body.Descendants())
+ {
+ var targetOp = GetAssignmentTargetOperation(descendant);
+ if (targetOp is null)
+ {
+ continue;
+ }
+
+ var target = GetReferencedSymbol(targetOp);
+ if (target is null)
+ {
+ continue;
+ }
+
+ // Captured iff the mutated symbol is not declared within this delegate (or a lambda
+ // nested inside it). Reads never reach here because only assignment targets are
+ // collected, so reading a captured variable is inherently allowed.
+ if (!declaredInDelegate.Contains(target))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.MutableCaptureInDurableOperation,
+ targetOp.Syntax.GetLocation(),
+ target.Name));
+ }
+ }
+ }
+
+ ///
+ /// Returns the target operation being mutated by an assignment, compound assignment, coalesce
+ /// assignment, or increment/decrement; otherwise null.
+ ///
+ private static IOperation? GetAssignmentTargetOperation(IOperation operation)
+ {
+ return operation switch
+ {
+ ISimpleAssignmentOperation simple => simple.Target,
+ ICompoundAssignmentOperation compound => compound.Target,
+ ICoalesceAssignmentOperation coalesce => coalesce.Target,
+ IIncrementOrDecrementOperation incDec => incDec.Target,
+ _ => null,
+ };
+ }
+
+ private static ISymbol? GetReferencedSymbol(IOperation targetOp)
+ {
+ return targetOp switch
+ {
+ ILocalReferenceOperation local => local.Local,
+ IParameterReferenceOperation parameter => parameter.Parameter,
+ _ => null,
+ };
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs
new file mode 100644
index 000000000..f32184181
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs
@@ -0,0 +1,124 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE002 — flags a durable operation invoked inside a step-wrapped delegate (a StepAsync body, a
+ /// WaitForCallback submitter, or a WaitForCondition check) by capturing the outer durable context.
+ /// In .NET a step delegate receives IStepContext, which exposes no durable operations, so
+ /// the only way to compile a nested durable call is to capture the outer IDurableContext —
+ /// which is exactly what this rule detects. Nesting durable operations requires
+ /// RunInChildContextAsync.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class NestedDurableOperationAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.NestedDurableOperationInsideStep);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return;
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeInvocation(ctx, symbols),
+ OperationKind.Invocation);
+ });
+ }
+
+ private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var invocation = (IInvocationOperation)context.Operation;
+
+ // Is this call itself a durable operation?
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out var nestedOpName, out _))
+ {
+ return;
+ }
+
+ // The receiver must be a durable context. Static/extension calls (Instance == null) and
+ // non-durable receivers are ignored.
+ if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type))
+ {
+ return;
+ }
+
+ // Find the nearest enclosing durable delegate. We only care about step-wrapped bodies;
+ // nesting inside a child-context / parallel / map branch is legitimate.
+ var enclosing = DurableScope.FindNearestDurableDelegate(invocation, symbols);
+ if (!enclosing.Found || enclosing.Role != DurableDelegateRole.StepWrapped)
+ {
+ return;
+ }
+
+ // The receiver must be captured from OUTSIDE this step delegate. A step delegate's own
+ // parameter is IStepContext (not a durable context), so any durable-context receiver here
+ // is necessarily captured — but we verify the symbol is not declared inside the delegate
+ // to stay correct for child/parallel branches that legitimately receive their own context.
+ var receiverSymbol = GetReferencedSymbol(invocation.Instance);
+ if (receiverSymbol is not null)
+ {
+ var declaredInDelegate = DurableScope.CollectDeclaredSymbols(enclosing.Function);
+ if (declaredInDelegate.Contains(receiverSymbol))
+ {
+ return; // Receiver is local to the step delegate — not a captured outer context.
+ }
+ }
+
+ var contextName = receiverSymbol?.Name ?? "context";
+ var bodyKind = DescribeBody(enclosing.OperationName);
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.NestedDurableOperationInsideStep,
+ invocation.Syntax.GetLocation(),
+ nestedOpName,
+ contextName,
+ bodyKind));
+ }
+
+ private static ISymbol? GetReferencedSymbol(IOperation receiver)
+ {
+ switch (receiver)
+ {
+ case ILocalReferenceOperation local:
+ return local.Local;
+ case IParameterReferenceOperation parameter:
+ return parameter.Parameter;
+ case IFieldReferenceOperation field:
+ return field.Field;
+ default:
+ return null;
+ }
+ }
+
+ private static string DescribeBody(string enclosingOperationName)
+ {
+ switch (enclosingOperationName)
+ {
+ case "WaitForConditionAsync":
+ return "condition-check";
+ case "WaitForCallbackAsync":
+ return "callback-submitter";
+ default:
+ return "step";
+ }
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs
new file mode 100644
index 000000000..ea3c00b8d
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs
@@ -0,0 +1,77 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE001 — flags non-deterministic API usage (DateTime.Now, Guid.NewGuid(), Random, …) in
+ /// durable workflow code that is not inside a step. On replay the workflow re-runs from the top,
+ /// so such values differ between the original execution and replays. The sanctioned place for
+ /// non-determinism is inside a step (or a WaitForCallback submitter / WaitForCondition check,
+ /// which the SDK also runs inside a step), where the result is checkpointed.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class NonDeterministicCallAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return; // Project does not reference the durable SDK; nothing to analyze.
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeOperation(ctx, symbols),
+ OperationKind.PropertyReference,
+ OperationKind.Invocation,
+ OperationKind.ObjectCreation);
+ });
+ }
+
+ private static void AnalyzeOperation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var operation = context.Operation;
+
+ var api = symbols.TryGetNonDeterministicApi(operation);
+ if (api is null)
+ {
+ return;
+ }
+
+ // Only flag inside durable workflow code (a method/lambda taking an IDurableContext).
+ if (!DurableScope.IsInWorkflowCode(operation, symbols))
+ {
+ return;
+ }
+
+ // Suppress when the nearest enclosing durable delegate is step-wrapped — non-determinism
+ // is allowed (and expected) there because its result is checkpointed.
+ var enclosing = DurableScope.FindNearestDurableDelegate(operation, symbols);
+ if (enclosing.Found && enclosing.Role == DurableDelegateRole.StepWrapped)
+ {
+ return;
+ }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.NonDeterministicCallOutsideStep,
+ operation.Syntax.GetLocation(),
+ api));
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs
new file mode 100644
index 000000000..8d839635c
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs
@@ -0,0 +1,231 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Formatting;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Code fix for DE001: wraps a non-deterministic expression in a step so its value is checkpointed
+ /// (await context.StepAsync((_, _) => Task.FromResult(E))). Offered as a single-occurrence
+ /// quick fix only — never a fix-all — because inserting a step shifts the position-derived
+ /// operation IDs of subsequent durable calls, which would break replay for already-running
+ /// executions. The fix is withheld for shapes it cannot safely rewrite.
+ ///
+ [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NonDeterministicCallCodeFixProvider))]
+ [Shared]
+ public sealed class NonDeterministicCallCodeFixProvider : CodeFixProvider
+ {
+ private const string IDurableContextMetadataName = DurableKnownSymbols.IDurableContextMetadataName;
+
+ ///
+ public override ImmutableArray FixableDiagnosticIds { get; } =
+ ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep.Id);
+
+ ///
+ /// Fix-all is intentionally disabled: inserting a step shifts the position-derived operation
+ /// IDs of every subsequent durable call, which would break replay for in-flight executions.
+ ///
+ public override FixAllProvider? GetFixAllProvider() => null;
+
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ var diagnostic = context.Diagnostics[0];
+ var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
+ var expression = node as ExpressionSyntax ?? node.FirstAncestorOrSelf();
+ if (expression is null)
+ {
+ return;
+ }
+
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ if (semanticModel is null)
+ {
+ return;
+ }
+
+ if (!IsSafelyWrappable(expression, semanticModel, context.CancellationToken, out var contextName))
+ {
+ return;
+ }
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: $"Wrap in {contextName}.StepAsync(...)",
+ createChangedDocument: ct => WrapInStepAsync(context.Document, root, expression, contextName, ct),
+ equivalenceKey: "WrapInStepAsync"),
+ diagnostic);
+ }
+
+ private static bool IsSafelyWrappable(
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken,
+ out string contextName)
+ {
+ contextName = "context";
+
+ // Guard: the expression must not itself contain an await, out/ref/in argument, or be a
+ // method group / lambda — wrapping any of those produces non-compiling or wrong code.
+ if (expression.DescendantNodesAndSelf().Any(n => n is AwaitExpressionSyntax))
+ {
+ return false;
+ }
+
+ foreach (var argument in expression.DescendantNodes().OfType())
+ {
+ if (!argument.RefKindKeyword.IsKind(SyntaxKind.None))
+ {
+ return false;
+ }
+ }
+
+ // The expression must produce a usable value (object creation of e.g. Random is flagged by
+ // DE001 but is not meaningfully wrappable into a checkpointed value — skip it).
+ if (expression is ObjectCreationExpressionSyntax)
+ {
+ return false;
+ }
+
+ var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken);
+ if (typeInfo.Type is null || typeInfo.Type.TypeKind == TypeKind.Error || typeInfo.Type.SpecialType == SpecialType.System_Void)
+ {
+ return false;
+ }
+
+ // await is only legal inside an async context; require one.
+ if (!IsInAsyncContext(expression))
+ {
+ return false;
+ }
+
+ // Find an in-scope IDurableContext to call StepAsync on.
+ var ctxName = FindDurableContextName(expression, semanticModel, cancellationToken);
+ if (ctxName is null)
+ {
+ return false;
+ }
+
+ contextName = ctxName;
+ return true;
+ }
+
+ private static bool IsInAsyncContext(SyntaxNode node)
+ {
+ for (var current = node.Parent; current is not null; current = current.Parent)
+ {
+ switch (current)
+ {
+ case MethodDeclarationSyntax m:
+ return m.Modifiers.Any(SyntaxKind.AsyncKeyword);
+ case LocalFunctionStatementSyntax lf:
+ return lf.Modifiers.Any(SyntaxKind.AsyncKeyword);
+ case AnonymousFunctionExpressionSyntax af:
+ return af.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword);
+ }
+ }
+
+ return false;
+ }
+
+ private static string? FindDurableContextName(
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken)
+ {
+ var durableContextType = semanticModel.Compilation.GetTypeByMetadataName(IDurableContextMetadataName);
+ if (durableContextType is null)
+ {
+ return null;
+ }
+
+ // Walk enclosing lambdas/methods looking for an IDurableContext-typed parameter.
+ for (var current = expression.Parent; current is not null; current = current.Parent)
+ {
+ var parameters = current switch
+ {
+ ParenthesizedLambdaExpressionSyntax pl => pl.ParameterList.Parameters,
+ SimpleLambdaExpressionSyntax sl => SyntaxFactory.SeparatedList(new[] { sl.Parameter }),
+ MethodDeclarationSyntax m => m.ParameterList.Parameters,
+ LocalFunctionStatementSyntax lf => lf.ParameterList.Parameters,
+ _ => default,
+ };
+
+ foreach (var parameter in parameters)
+ {
+ var symbol = semanticModel.GetDeclaredSymbol(parameter, cancellationToken) as IParameterSymbol;
+ if (symbol is not null && Implements(symbol.Type, durableContextType))
+ {
+ return symbol.Name;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static bool Implements(ITypeSymbol type, INamedTypeSymbol durableContextType)
+ {
+ if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, durableContextType))
+ {
+ return true;
+ }
+
+ return type.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, durableContextType));
+ }
+
+ private static Task WrapInStepAsync(
+ Document document,
+ SyntaxNode root,
+ ExpressionSyntax expression,
+ string contextName,
+ CancellationToken cancellationToken)
+ {
+ // await {ctx}.StepAsync((_, _) => System.Threading.Tasks.Task.FromResult(E))
+ var lambda = SyntaxFactory.ParenthesizedLambdaExpression(
+ SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[]
+ {
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("_")),
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("__")),
+ })),
+ SyntaxFactory.InvocationExpression(
+ SyntaxFactory.ParseExpression("System.Threading.Tasks.Task.FromResult"),
+ SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(expression.WithoutTrivia())))));
+
+ var stepCall = SyntaxFactory.AwaitExpression(
+ SyntaxFactory.InvocationExpression(
+ SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName(contextName),
+ SyntaxFactory.IdentifierName("StepAsync")),
+ SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(lambda)))));
+
+ var replacement = stepCall
+ .WithLeadingTrivia(expression.GetLeadingTrivia())
+ .WithTrailingTrivia(expression.GetTrailingTrivia())
+ .WithAdditionalAnnotations(Formatter.Annotation);
+
+ var newRoot = root.ReplaceNode(expression, replacement);
+ return Task.FromResult(document.WithSyntaxRoot(newRoot));
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj
index cb03b2715..632911276 100644
--- a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj
+++ b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj
@@ -32,6 +32,26 @@
+
+
+ ..\Amazon.Lambda.DurableExecution.Analyzers\
+
+
+
+
+
+
+
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md
new file mode 100644
index 000000000..bc498c10b
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md
@@ -0,0 +1,130 @@
+# Durable Execution Analyzers (DE001–DE004)
+
+`Amazon.Lambda.DurableExecution` ships a set of Roslyn analyzers that run in the IDE and during
+`dotnet build` to catch the most common durable-execution authoring mistakes before they become
+confusing runtime failures. The analyzers are bundled inside the `Amazon.Lambda.DurableExecution`
+NuGet package (`analyzers/dotnet/cs`), so they activate automatically for any project that references
+the package — no extra reference is required. They only run on projects that reference the durable SDK
+and only inside durable *workflow code* (a method, local function, or lambda that takes an
+`IDurableContext` parameter), so unrelated code in the same project is unaffected.
+
+These analyzers are the .NET counterpart of the JavaScript SDK's
+[`@aws/durable-execution-sdk-js-eslint-plugin`](https://github.com/aws/aws-durable-execution-sdk-js/tree/main/packages/aws-durable-execution-sdk-js-eslint-plugin),
+re-implemented with Roslyn's semantic model so durable operations are matched by symbol
+(`Amazon.Lambda.DurableExecution.IDurableContext`) rather than by method name.
+
+## Why determinism matters
+
+A durable workflow has no persisted program counter. On every invocation — including every replay
+after a wait, retry, or failure — your handler runs again from the top. Each durable call
+(`StepAsync`, `WaitAsync`, …) looks up its checkpoint and either replays the cached result or runs
+fresh. For the cached results to line up with the code, the workflow must execute the **same
+operations, in the same order, every time**. Code *outside* a step must therefore be deterministic;
+code *inside* a step may be non-deterministic because the step's result is checkpointed once.
+
+## Rules
+
+| ID | Severity | Rule |
+|-------|----------|------|
+| DE001 | Warning | Non-deterministic call outside a step |
+| DE002 | Warning | Nested durable operation inside a step body |
+| DE003 | Warning | Mutable variable captured and modified inside a durable operation |
+| DE004 | Info | `Task.WhenAll`/`Task.WhenAny` over durable tasks |
+
+### DE001 — Non-deterministic call outside a step
+
+Flags `DateTime.Now`/`UtcNow`/`Today`, `DateTimeOffset.Now`/`UtcNow`, `Guid.NewGuid()`,
+`new Random()` / `Random.Shared`, `Stopwatch.GetTimestamp()`/`StartNew()`/`Elapsed*`,
+`Environment.TickCount`/`TickCount64`, `RandomNumberGenerator`/`RNGCryptoServiceProvider`, and
+`Path.GetTempFileName()`/`GetRandomFileName()` when used in workflow code outside a step. The value
+would differ between the original execution and replays, corrupting checkpoint-derived state. A
+seeded `new Random(42)` is deterministic and is **not** flagged.
+
+```csharp
+// ❌ Flagged — different value on replay
+var now = DateTime.UtcNow;
+await context.StepAsync((s, ct) => DoWork(now));
+
+// ✅ Captured inside a step
+var now = await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow));
+await context.StepAsync((s, ct) => DoWork(now));
+```
+
+A code fix is offered that wraps the expression in `context.StepAsync(...)`. It is a single-occurrence
+quick fix only (no Fix All), because inserting a step shifts the position-derived operation IDs of
+subsequent durable calls and would break replay for already-running executions.
+
+### DE002 — Nested durable operation inside a step body
+
+Flags any durable operation (`StepAsync`, `WaitAsync`, `ParallelAsync`, `MapAsync`, `InvokeAsync`,
+`RunInChildContextAsync`, `CreateCallbackAsync`, `WaitForCallbackAsync`) invoked inside a *step-wrapped*
+delegate — a `StepAsync` body, a `WaitForCallbackAsync` submitter, or a `WaitForConditionAsync` check —
+by capturing the outer `IDurableContext`. Step bodies are leaf operations; group durable operations
+with `RunInChildContextAsync` instead.
+
+```csharp
+// ❌ Flagged — durable op nested in a step body
+await context.StepAsync(async (s, ct) =>
+{
+ await context.WaitAsync(TimeSpan.FromSeconds(1)); // uses the captured outer context
+});
+
+// ✅ Group with a child context
+await context.RunInChildContextAsync(async (child, ct) =>
+{
+ await child.StepAsync((s, c) => DoWork());
+ await child.WaitAsync(TimeSpan.FromSeconds(1));
+});
+```
+
+### DE003 — Mutable variable captured and modified inside a durable operation
+
+Flags assignment, compound assignment, or increment/decrement of a variable captured from an outer
+scope inside a durable-operation delegate (`StepAsync`, `RunInChildContextAsync`,
+`WaitForConditionAsync`, `WaitForCallbackAsync`, and `ParallelAsync`/`MapAsync` branches). On replay the
+body is skipped and the cached result returned, so the write never happens. Reading a captured variable
+is safe and is not flagged.
+
+```csharp
+// ❌ Flagged — write is lost on replay
+int total = 0;
+await context.StepAsync((s, ct) => { total += 1; return Task.CompletedTask; });
+
+// ✅ Return the value and assign it
+total = await context.StepAsync((s, ct) => Task.FromResult(total + 1));
+```
+
+### DE004 — `Task.WhenAll`/`Task.WhenAny` over durable tasks
+
+Advisory (Info). `Task.WhenAll`/`Task.WhenAny` work correctly with durable tasks (operation IDs are
+allocated deterministically), but they bypass completion policies, concurrency limits, branch naming,
+and structured `IBatchResult` output. Prefer `ParallelAsync`/`MapAsync`.
+
+```csharp
+// ℹ️ Suggested — works, but prefer ParallelAsync
+await Task.WhenAll(context.StepAsync(a), context.StepAsync(b));
+
+// ✅ Preferred
+await context.ParallelAsync(new Func>[]
+{
+ (c, ct) => c.StepAsync((s, t) => A()),
+ (c, ct) => c.StepAsync((s, t) => B()),
+});
+```
+
+A code fix converts the simplest safe shape (an inline list of same-typed durable calls on one context
+whose aggregate result is discarded) into `ParallelAsync`.
+
+## Configuring severity
+
+Each rule's severity can be overridden per project via `.editorconfig`:
+
+```ini
+# Treat the non-determinism rule as an error, silence the WhenAll suggestion.
+dotnet_diagnostic.DE001.severity = error
+dotnet_diagnostic.DE004.severity = none
+```
+
+Note: if your project sets `true`, the Warning-level
+rules (DE001–DE003) will fail the build. Lower their severity in `.editorconfig` if you prefer them as
+warnings during the preview.
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj
new file mode 100644
index 000000000..a9f3df12c
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net8.0
+ latest
+ enable
+ false
+ false
+
+ $(NoWarn);CS0618
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs
new file mode 100644
index 000000000..2e7c36ebd
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs
@@ -0,0 +1,67 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ ///
+ /// A faithful (signature-only) source copy of the durable-execution surface the analyzers match
+ /// against. Injected into every test compilation as source so tests do not need to reference the
+ /// real Amazon.Lambda.DurableExecution package (which pulls AWSSDK and is awkward in the analyzer
+ /// test harness). The analyzers resolve these by metadata name, so the stub namespace and member
+ /// shapes must match the real SDK exactly.
+ ///
+ internal static class DurableStubs
+ {
+ internal const string Source = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Amazon.Lambda.DurableExecution
+{
+ public interface IStepContext { int AttemptNumber { get; } string OperationId { get; } }
+ public interface IConditionCheckContext { }
+ public interface IWaitForCallbackContext { }
+ public interface IExecutionContext { string DurableExecutionArn { get; } }
+ public interface ICallback { string CallbackId { get; } Task GetResultAsync(CancellationToken ct = default); }
+
+ public sealed class StepConfig { }
+ public sealed class ChildContextConfig { }
+ public sealed class CallbackConfig { }
+ public sealed class WaitForCallbackConfig { }
+ public sealed class InvokeConfig { }
+ public sealed class ParallelConfig { }
+ public sealed class MapConfig { }
+ public sealed class WaitForConditionConfig { }
+ public readonly struct DurableBranch { public DurableBranch(string name, Func> func) { } }
+ public interface IBatchResult { }
+
+ public interface IDurableContext
+ {
+ IExecutionContext ExecutionContext { get; }
+
+ Task StepAsync(Func> func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default);
+ Task StepAsync(Func func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default);
+
+ Task WaitAsync(TimeSpan duration, string name = null, CancellationToken cancellationToken = default);
+
+ Task RunInChildContextAsync(Func> func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default);
+ Task RunInChildContextAsync(Func func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default);
+
+ Task> CreateCallbackAsync(string name = null, CallbackConfig config = null, CancellationToken cancellationToken = default);
+ Task WaitForCallbackAsync(Func submitter, string name = null, WaitForCallbackConfig config = null, CancellationToken cancellationToken = default);
+
+ Task InvokeAsync(string functionName, TPayload payload, string name = null, InvokeConfig config = null, CancellationToken cancellationToken = default);
+
+ Task WaitForConditionAsync(Func> check, WaitForConditionConfig config, string name = null, CancellationToken cancellationToken = default);
+
+ Task> ParallelAsync(IReadOnlyList>> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default);
+ Task> ParallelAsync(IReadOnlyList> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default);
+
+ Task> MapAsync(IReadOnlyList items, Func, CancellationToken, Task> func, string name = null, MapConfig config = null, CancellationToken cancellationToken = default);
+ }
+}
+";
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs
new file mode 100644
index 000000000..d79e02e31
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs
@@ -0,0 +1,108 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class DurableTaskCombinatorAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task WhenAll_OverInlineDurableTasks_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await {|#0:Task.WhenAll(
+ context.StepAsync((s, ct) => Task.FromResult(1)),
+ context.StepAsync((s, ct) => Task.FromResult(2)))|};
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAll");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task WhenAny_OverInlineDurableTasks_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await {|#0:Task.WhenAny(
+ context.StepAsync((s, ct) => Task.FromResult(1)),
+ context.StepAsync((s, ct) => Task.FromResult(2)))|};
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAny");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task WhenAll_OverTaskArrayLocal_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var tasks = new List>();
+ tasks.Add(context.StepAsync((s, ct) => Task.FromResult(1)));
+ await {|#0:Task.WhenAll(tasks)|};
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAll");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task WhenAll_OverNonDurableTasks_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await Task.WhenAll(Task.Delay(1), Task.Delay(2));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task ParallelAsync_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.ParallelAsync(new Func>[]
+ {
+ (c, ct) => c.StepAsync((s, t) => Task.FromResult(1)),
+ (c, ct) => c.StepAsync((s, t) => Task.FromResult(2)),
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs
new file mode 100644
index 000000000..12e7a4d28
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs
@@ -0,0 +1,49 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer,
+ Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorCodeFixProvider>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class DurableTaskCombinatorCodeFixTests
+ {
+ private const string Usings = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task ConvertsWhenAll_ToParallelAsync_WhenResultDiscarded()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await {|#0:Task.WhenAll(
+ context.StepAsync((s, ct) => Task.FromResult(1)),
+ context.StepAsync((s, ct) => Task.FromResult(2)))|};
+ }
+}";
+ var fixedSource = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.ParallelAsync(new System.Func>[] { (c, ct) => c.StepAsync((s, ct) => Task.FromResult(1)), (c, ct) => c.StepAsync((s, ct) => Task.FromResult(2)) });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAll");
+ await Verify.VerifyAsync(source, fixedSource, expected);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs
new file mode 100644
index 000000000..52c487f78
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs
@@ -0,0 +1,184 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.MutableCaptureAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class MutableCaptureAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task SimpleAssignment_OfCapturedVariable_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int counter = 0;
+ await context.StepAsync((s, ct) => { {|#0:counter|} = 5; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("counter");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task CompoundAssignment_OfCapturedVariable_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int total = 0;
+ await context.StepAsync((s, ct) => { {|#0:total|} += 1; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("total");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task Increment_OfCapturedVariable_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int n = 0;
+ await context.StepAsync((s, ct) => { {|#0:n|}++; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("n");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task ReadingCapturedVariable_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int seed = 41;
+ await context.StepAsync((s, ct) => Task.FromResult(seed + 1));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task MutatingDelegateLocal_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) =>
+ {
+ int local = 0;
+ local += 1;
+ return Task.FromResult(local);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task ShadowingLocal_IsNotFlagged()
+ {
+ // An inner local that shadows an outer name is delegate-local by symbol identity.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int total = 100;
+ await context.StepAsync((s, ct) =>
+ {
+ int total2 = 0;
+ total2 += 1;
+ return Task.FromResult(total2);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task MutatingCapturedVariable_InChildContext_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int flag = 0;
+ await context.RunInChildContextAsync((child, ct) => { {|#0:flag|} = 1; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("flag");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task MutatingPerItemAndBranchLocals_InParallel_IsNotFlagged()
+ {
+ // The branch delegate's own parameters and locals are not captures.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.ParallelAsync(new Func>[]
+ {
+ (branch, ct) => { int x = 0; x += 1; return Task.FromResult(x); },
+ (branch, ct) => Task.FromResult(2),
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task MutatingCapturedVariable_FromParallelBranch_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int shared = 0;
+ await context.ParallelAsync(new Func>[]
+ {
+ (branch, ct) => { {|#0:shared|} += 1; return Task.FromResult(shared); },
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("shared");
+ await Verify.VerifyAsync(source, expected);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs
new file mode 100644
index 000000000..6493dba53
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs
@@ -0,0 +1,153 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.NestedDurableOperationAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class NestedDurableOperationAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task DurableOp_InsideStepBody_ViaCapturedContext_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync(async (s, ct) =>
+ {
+ await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|};
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("WaitAsync", "context", "step");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task NestedStepAsync_InsideStepBody_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync(async (s, ct) =>
+ {
+ await {|#0:context.StepAsync((s2, ct2) => Task.CompletedTask)|};
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("StepAsync", "context", "step");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task DurableOp_InsideChildContext_OnChildContext_IsNotFlagged()
+ {
+ // Using the child's own context inside a child context is the SANCTIONED nesting pattern.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.RunInChildContextAsync(async (child, ct) =>
+ {
+ await child.StepAsync((s, c) => Task.CompletedTask);
+ await child.WaitAsync(TimeSpan.FromSeconds(1));
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DurableOp_DirectlyInWorkflowBody_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) => Task.CompletedTask);
+ await context.WaitAsync(TimeSpan.FromSeconds(1));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DurableOp_InsideConditionCheck_ViaCapturedContext_IsFlagged()
+ {
+ // WaitForConditionAsync's check delegate is step-wrapped at runtime.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.WaitForConditionAsync(async (state, cc, ct) =>
+ {
+ await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|};
+ return state + 1;
+ }, new WaitForConditionConfig());
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("WaitAsync", "context", "condition-check");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task DurableOp_InsideCallbackSubmitter_ViaCapturedContext_IsFlagged()
+ {
+ // WaitForCallbackAsync's submitter delegate is step-wrapped at runtime.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.WaitForCallbackAsync(async (callbackId, cc, ct) =>
+ {
+ await {|#0:context.StepAsync((s, c) => Task.CompletedTask)|};
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("StepAsync", "context", "callback-submitter");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task ConfigureLogger_LikeMembers_NotTreatedAsDurableOps()
+ {
+ // Reading ExecutionContext (a property, not a durable op) inside a step is fine.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) =>
+ {
+ var arn = context.ExecutionContext.DurableExecutionArn;
+ return Task.FromResult(arn);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs
new file mode 100644
index 000000000..144344689
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs
@@ -0,0 +1,211 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class NonDeterministicCallAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task DateTimeNow_InWorkflowBody_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var now = {|#0:DateTime.Now|};
+ await context.StepAsync((s, ct) => Task.FromResult(now));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.Now");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task DateTimeUtcNow_InsideStep_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task GuidNewGuid_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var id = {|#0:Guid.NewGuid()|};
+ await context.StepAsync((s, ct) => Task.FromResult(id.ToString()));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("Guid.NewGuid()");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task NewRandom_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var r = {|#0:new Random()|};
+ await context.StepAsync((s, ct) => Task.FromResult(r.Next()));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("new Random()");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task SeededRandom_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var r = new Random(42);
+ await context.StepAsync((s, ct) => Task.FromResult(r.Next()));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task RandomShared_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var n = {|#0:Random.Shared|}.Next();
+ await context.StepAsync((s, ct) => Task.FromResult(n));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("Random.Shared");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task NonDeterminism_NestedInNonDurableLambdaInsideStep_IsNotFlagged()
+ {
+ // DateTime.UtcNow inside a LINQ projection that itself runs inside a step is fine —
+ // the enclosing durable delegate (StepAsync) is step-wrapped.
+ var source = Usings + @"
+using System.Linq;
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) =>
+ {
+ var times = Enumerable.Range(0, 3).Select(x => DateTime.UtcNow).ToArray();
+ return Task.FromResult(times.Length);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DateTimeNow_InNonWorkflowMethod_IsNotFlagged()
+ {
+ // A method with no IDurableContext parameter is not workflow code.
+ var source = Usings + @"
+class W
+{
+ public DateTime Helper() => DateTime.Now;
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DateTimeNow_InsideChildContextBranch_IsFlagged()
+ {
+ // A child-context body is still workflow code that must be deterministic.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.RunInChildContextAsync(async (child, ct) =>
+ {
+ var now = {|#0:DateTime.Now|};
+ await child.StepAsync((s, c) => Task.FromResult(now));
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.Now");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task UnrelatedStepMethodOnOtherType_DoesNotSuppress()
+ {
+ // Semantic matching: an unrelated type's StepAsync must NOT be treated as a durable step,
+ // so it does not suppress non-determinism. DateTime.Now nested in it is still workflow code
+ // outside any real durable step, so it IS flagged.
+ var source = Usings + @"
+class NotDurable { public Task StepAsync(Func> f) => f(); }
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var other = new NotDurable();
+ await other.StepAsync(() => Task.FromResult({|#0:DateTime.Now|}));
+ await context.StepAsync((s, ct) => Task.CompletedTask);
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.Now");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task UnrelatedTypeProducesNoDiagnostic_OutsideWorkflow()
+ {
+ // The same unrelated StepAsync, but in a method with no IDurableContext — not workflow code.
+ var source = Usings + @"
+class NotDurable { public Task StepAsync(Func> f) => f(); }
+class W
+{
+ public async Task Helper()
+ {
+ var other = new NotDurable();
+ await other.StepAsync(() => Task.FromResult(DateTime.Now));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs
new file mode 100644
index 000000000..ccedd3171
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs
@@ -0,0 +1,48 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer,
+ Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallCodeFixProvider>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class NonDeterministicCallCodeFixTests
+ {
+ private const string Usings = @"
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task WrapsDateTimeNow_InStepAsync()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var now = {|#0:DateTime.UtcNow|};
+ return now;
+ }
+}";
+ var fixedSource = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var now = await context.StepAsync((_, __) => System.Threading.Tasks.Task.FromResult(DateTime.UtcNow));
+ return now;
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.UtcNow");
+ await Verify.VerifyAsync(source, fixedSource, expected);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs
new file mode 100644
index 000000000..4e5e77810
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs
@@ -0,0 +1,74 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.CodeAnalysis.Testing.Verifiers;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ ///
+ /// Analyzer-only verifier that injects the durable stub surface into every test compilation and
+ /// targets the net8.0 reference assemblies.
+ ///
+ internal static class AnalyzerVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ {
+ internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) =>
+ new DiagnosticResult(descriptor);
+
+ internal static Task VerifyAsync(string source, params DiagnosticResult[] expected)
+ {
+ var test = new Test { TestCode = source };
+ test.ExpectedDiagnostics.AddRange(expected);
+ return test.RunAsync(CancellationToken.None);
+ }
+
+ private sealed class Test : CSharpAnalyzerTest
+ {
+ public Test()
+ {
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net80;
+ TestState.Sources.Add(DurableStubs.Source);
+ }
+ }
+ }
+
+ ///
+ /// Code-fix verifier mirroring with the stub injected
+ /// into both the pre-fix and post-fix compilations.
+ ///
+ internal static class CodeFixVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ where TCodeFix : CodeFixProvider, new()
+ {
+ internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) =>
+ new DiagnosticResult(descriptor);
+
+ internal static Task VerifyAsync(string source, string fixedSource, params DiagnosticResult[] expected)
+ {
+ var test = new Test
+ {
+ TestCode = source,
+ FixedCode = fixedSource,
+ };
+ test.ExpectedDiagnostics.AddRange(expected);
+ return test.RunAsync(CancellationToken.None);
+ }
+
+ private sealed class Test : CSharpCodeFixTest
+ {
+ public Test()
+ {
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net80;
+ TestState.Sources.Add(DurableStubs.Source);
+ FixedState.Sources.Add(DurableStubs.Source);
+ }
+ }
+ }
+}