WoofWare.FSharpAnalyzers 0.2.9

dotnet add package WoofWare.FSharpAnalyzers --version 0.2.9
                    
NuGet\Install-Package WoofWare.FSharpAnalyzers -Version 0.2.9
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="WoofWare.FSharpAnalyzers" Version="0.2.9">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="WoofWare.FSharpAnalyzers" Version="0.2.9" />
                    
Directory.Packages.props
<PackageReference Include="WoofWare.FSharpAnalyzers">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add WoofWare.FSharpAnalyzers --version 0.2.9
                    
#r "nuget: WoofWare.FSharpAnalyzers, 0.2.9"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package WoofWare.FSharpAnalyzers@0.2.9
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=WoofWare.FSharpAnalyzers&version=0.2.9
                    
Install as a Cake Addin
#tool nuget:?package=WoofWare.FSharpAnalyzers&version=0.2.9
                    
Install as a Cake Tool

WoofWare.FSharpAnalyzers

A set of F# source analyzers, using the Ionide analyzer SDK.

They are modelled on the G-Research analyzers, but are much more opinionated. They're intended for my personal use.

If you find false negatives or false positives, please do raise GitHub issues! (Though I reserve the right just to say I'm happy with the status quo.)

Analyzers

MissingCancellationTokenAnalyzer

Prompts you to use overloads of Task/ValueTask-returning methods that take CancellationTokens.

Use the suppression comment "fsharpanalyzer: ignore-line WOOF-MISSING-CT" to suppress the analyzer.

Rationale

.NET's cooperative multitasking requires you to thread CancellationTokens around the code if you want your asynchronous operations to be cancellable. Nevertheless, idiomatic .NET APIs let you simply not do that by default, which means by default your code won't be cancellable. This analyzer detects when you've fallen into that pit of failure.

BlockingCallsAnalyzer

Bans the use of blocking calls like Async.RunSynchronously.

You will have to have a blocking call in your main method; use the suppression comment "fsharpanalyzer: ignore-line WOOF-BLOCKING" to guard that line.

Rationale

Prevent sync-over-async.

SuppressThrowingGenericAnalyzer

Detects use of ConfigureAwaitOptions.SuppressThrowing with generic Task<TResult>. (For some reason, ValueTask doesn't accept ConfigureAwaitOptions.)

Use the suppression comment "fsharpanalyzer: ignore-line WOOF-SUPPRESS-THROWING-GENERIC" to suppress the analyzer; but note that Microsoft says this code pattern is always wrong.

Rationale

The ConfigureAwaitOptions.SuppressThrowing option is not supported by generic Task<TResult> because it can lead to returning an invalid TResult when an exception occurs. This is a runtime error that should be caught at build time.

If you need to use SuppressThrowing, cast the task to non-generic Task first:

let t = Task.FromResult(42)
// Bad: will fail at runtime
let! _ = t.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing)

// Good: cast to Task first
do! (t :> Task).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing)

This analyzer mirrors the C# analyzer CA2261, surfacing the error at build time rather than run time.

Limitations

We don't try hard to follow e.g. options defined through a let-binding, so false negatives are possible.

ReferenceEqualsAnalyzer

Bans the use of Object.ReferenceEquals.

Use the suppression comment "fsharpanalyzer: ignore-line WOOF-REFEQUALS" to suppress the analyzer. (If you define a type-safe version of ReferenceEquals - see the next section - then you will have to specify the suppression inside that function.)

Rationale

Object.ReferenceEquals has two significant problems:

  1. It silently does the wrong thing on value types. When you pass value types to Object.ReferenceEquals, they get boxed, and the function compares the boxed instances rather than the original values. This means Object.ReferenceEquals(42, 42) will always return false, which is rarely what you want.

  2. It lacks type safety. The function accepts any two objects, making it too easy to accidentally compare objects of completely different types (e.g., Object.ReferenceEquals("hello", 42)), which will always return false but compiles without warning.

Instead, use a type-safe wrapper that enforces reference type constraints and type consistency:

let referenceEquals<'a when 'a : not struct> (x : 'a) (y : 'a) : bool =
    obj.ReferenceEquals(x, y)

This prevents both issues: the not struct constraint prevents value types from being passed, and the type parameter 'a ensures both arguments are the same type.

StreamReadAnalyzer

Detects calls to Stream.Read and Stream.ReadAsync where the return value is explicitly ignored.

Use the suppression comment fsharpanalyzer: ignore-line WOOF-STREAM-READ to suppress the analyzer.

Rationale

Stream.Read and Stream.ReadAsync are not guaranteed to read the requested number of bytes. They may return fewer bytes than requested for various reasons:

  • End of stream is reached
  • Network conditions (for network streams)
  • Implementation-specific buffering

This can lead to subtle bugs where code assumes a full buffer was read when only partial data was actually received. Always check the return value to determine how many bytes were actually read.

If you need to read an exact number of bytes and throw if fewer are available, use Stream.ReadExactly or Stream.ReadExactlyAsync instead (available in .NET 7+).

What this analyzer detects

The analyzer flags these specific patterns where the return value is discarded:

  1. Piping to ignore: stream.Read(...) |> ignore or stream.ReadAsync(...) |> ignore
  2. Calling ignore directly: ignore (stream.Read(...)) or ignore (stream.ReadAsync(...))
  3. Assignment to underscore (synchronous): let _ = stream.Read(...)
  4. Unused binding in computation expressions: let! _ = stream.ReadAsync(...) or any let! binding of a Stream.ReadAsync call where the bound result is not used

TaskCompletionSourceAnalyzer

Requires TaskCompletionSource<T> to be created with TaskCreationOptions.RunContinuationsAsynchronously.

Use the suppression comment fsharpanalyzer: ignore-line WOOF-TCS-ASYNC to suppress the analyzer.

Rationale

By default, when you call SetResult, SetException, or SetCanceled on a TaskCompletionSource<T>, any continuations attached to the resulting task will run synchronously on the thread that completes the task. This can lead to serious problems:

  1. Deadlocks: If the continuation tries to acquire a lock or synchronization context that the calling thread holds, you get a deadlock.

  2. Thread-pool starvation: Continuations may perform long-running work, blocking the thread that called SetResult and preventing it from doing other work.

  3. State corruption: The continuation runs with the calling thread's execution context, which may have unexpected side effects like running on a UI thread or within a specific synchronization context.

Always create TaskCompletionSource<T> with TaskCreationOptions.RunContinuationsAsynchronously to ensure continuations are scheduled asynchronously on the thread pool:

let tcs = TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously)

EarlyReturnAnalyzer

Detects return and return! expressions used in non-terminal positions inside async { }, task { }, and related computation expressions.

Use the suppression comment fsharpanalyzer: ignore-line WOOF-EARLY-RETURN to suppress the analyzer.

Rationale

In computation expressions, return simply builds a value for the builder; it does not exit the computation the way imperative languages do. Any code following the return (other statements, loop iterations, finally blocks, etc) still runs.

Here is a concrete computation expression and desugaring that demonstrates the problem:

async {
    if true then
        return ()
    printfn "hi!"
    return ()
}
fun () ->
    async.Combine (
        if true then
            async.Return ()
        else
            async.Zero ()
        ,
        async.Delay (fun () ->
            printfn "hi!"
            async.Return ()
        )
    )
|> async.Delay

Notice that the initial if branch has not caused any kind of short-circuiting; we continue unconditionally to the Delay.

This analyzer highlights those non-terminal return calls so you can restructure the logic using explicit if/else or match patterns that clearly indicate what happens in every branch.

(GPT-5 wanted me to clarify this point, although I think nobody would expect different behaviour: we don't flag return statements after a use call, even though disposal is code that runs after the return statement. Leaving the scope by any means, including a return, should intuitively trigger the disposal; which is indeed what happens.)

ThrowingInDisposeAnalyzer

Bans throwing in a Dispose implementation.

Use the suppression comment "fsharpanalyzer: ignore-line WOOF-THROWING-DISPOSE" to suppress the analyzer.

Rationale

See the C# analyzer. Basically, users find it very confusing when a finally clause still needs exception handling inside it.

Any Dispose (isDisposing = false) code path (conventionally called by the finaliser thread) is especially bad to throw in, because such errors are completely recoverable.

ReturnBangOnlyAnalyzer

Detects computation expressions that consist only of return!, which is likely unnecessary indirection.

Use the suppression comment "fsharpanalyzer: ignore-line WOOF-RETURN-BANG-ONLY" to suppress the analyzer.

Rationale

A computation expression like async { return! x } where x is already an Async<'T> is almost always unnecessary indirection - you can just use x directly. For example:

// Unnecessary indirection
let simpleAsync (x : Async<int>) = async { return! x }

// Better - just use the value directly
let simpleAsync (x : Async<int>) = x

They're not quite the same, because the return! phrasing can involve a call to builder.Delay and/or builder.Run, but I claim that this is a baroque and needlessly clever way to phrase that. If you genuinely need the Delay behavior for laziness, consider making this explicit with a function or documenting why the indirection is necessary.

Licence

WoofWare.FSharpAnalyzers is licensed to you under the MIT licence, a copy of which can be found at LICENSE.md.

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.9 168 11/25/2025
0.2.8 171 11/25/2025
0.2.7 163 11/24/2025
0.2.6 166 11/23/2025
0.2.5 163 11/23/2025
0.2.4 129 11/23/2025
0.2.3 380 11/20/2025
0.2.2 298 11/12/2025
0.2.1 203 10/20/2025
0.1.5 166 10/19/2025
0.1.4 164 10/19/2025
0.1.3 385 10/4/2025
0.1.2 121 10/3/2025
0.1.1 156 10/3/2025