fsharp-refactor 0.8.22

There is a newer version of this package available.
See the version list below for details.
dotnet tool install --global fsharp-refactor --version 0.8.22
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local fsharp-refactor --version 0.8.22
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=fsharp-refactor&version=0.8.22
                    
nuke :add-package fsharp-refactor --version 0.8.22
                    

FSharp.Refactor

Let's remove parentheses from the internet

Functional refactoring suggestions for F#.

  • light bulb quick fixes in your editor
  • command-line tool that applies them in bulk.

Suggestions are Hint severity: they mark an opportunity, not a defect, and never gate your build.


Using it

Quick start

Nothing to configure — the tool reads your project, reports what it would change, and only edits when you tell it to:

dotnet tool install --global fsharp-refactor
fsharp-refactor Your.fsproj --dry-run

That prints every fix it would make, with file and position, and writes nothing. When the list looks right, drop the flag to apply them:

fsharp-refactor Your.fsproj

It refuses a compilation that does not already build, and fails loudly if applying ever introduces an error.

If you are ready to change public methods, add --api-changes to improve more.

For light bulbs while you type, see VS Code / Ionide and Visual Studio IDE-plugin instructions below.

<img width="1136" height="215" alt="image" src="https://github.com/user-attachments/assets/cd4e3ed3-1ca6-40b6-bab4-da590e6d0410" />

For agentic scenarios the dotnet tool supports MCP.

Using the tool: Point it at whatever you have — the kind is read off the path:

Your.fsproj one project
Thing.fs one source file — its project is found and analysed, but only that file is edited
build.fsx one script — no MSBuild step at all, so it starts instantly. A script with unresolvable references is not refused: the syntactic rules still run over it (an fsi-run script may reference things only the run supplies)
Your.sln, Your.slnx every F# project the solution lists
src/ the solution in that directory, or the projects beneath it
"src/**/*.fsproj" everything the glob matches

What it changes

Examples:

Before After
Correctness let s = new FileStream(p, m) use s = new FileStream(p, m)
Correctness raise (Exception "boom") failwith "boom"
Performance s.Contains "x" s.Contains 'x'
Performance xs \|> Seq.toList \|> List.map f xs \|> Seq.map f \|> Seq.toList
Idiom match b with \| true -> 1 \| false -> 0 if b then 1 else 0
Redundancy new StringBuilder() StringBuilder()
Redundancy [<SerializableAttribute>] [<Serializable>]
Diagnostics failwith "Error" failwith $"Error, calling f with x: {x}"

A spread of what the 150-odd rules do — the full list is in Refactorings:

Editor and CI setup

The analyzers ship as FSharp.Refactor.Analyzers. The package is a development dependency: it only produces hints and quick fixes — nothing from it flows into your compiled output.

NOTE: EVEN WHEN ADDING A NUGET REFERENCE, THIS ANALYSER WILL NOT COME TO OUTPUT PATH (BIN) AND WILL NOT BE PART OF YOUR PROJECT.

VS Code / Ionide

Easiest: install the FSharp.Refactor VS Code extension — it bundles the analyzers and (with your consent) wires them into Ionide's settings globally, so every F# project gets the hints with no per-project setup.

Or wire a single project by hand: reference the package from the project you want analyzed:

<PackageReference Include="FSharp.Refactor.Analyzers" Version="*" PrivateAssets="all" />

then point Ionide at the restored analyzers in .vscode/settings.json:

{
  "FSharp.enableAnalyzers": true,
  "FSharp.analyzersPath": [
    "~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs"
  ]
}

replacing <version> with the version restore actually picked (the NuGet cache always keys folders by version, so this one path cannot float; check with ls ~/.nuget/packages/fsharp.refactor.analyzers/). On Windows the cache lives under %USERPROFILE%\.nuget\packages. Suggestions appear as Hint-severity diagnostics with a light-bulb one-click fix.

The package ships the analyzers built against TWO FSharp.Analyzers.SDK versions side by side — FSharp.Refactor.Analyzers.Ionide.dll for SDK 0.35.0 (what stock Ionide's FsAutoComplete bundles, 7.31.x and earlier) and FSharp.Refactor.Analyzers.dll for SDK 0.37.2 (the CLI, and FsAutoComplete 0.84+). The SDK loads only the assembly matching its own version and logs a skip line for the other, so every host picks the one it can use and no configuration choice is needed.

If no suggestions appear, open Output → "F# Language Service": it names the analyzer dlls it scanned and how many analyzers loaded, and a version-pairing skip is spelt out there rather than surfacing in the editor.

Visual Studio 2022–2026

Install the FSharp.Refactor Visual Studio extension (user-facing overview): squiggles and Ctrl+. quick fixes in full Visual Studio, analyzed through an FsAutoComplete sidecar. One prerequisite: dotnet tool install -g fsautocomplete.

CLI / CI

dotnet tool install --global fsharp-analyzers
fsharp-analyzers --project src/YourProject.fsproj --analyzers-path ~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs --code-root . --report analysis.sarif

fsharp-analyzers is the analyzer HOST, and it is the dotnet tool you install. This package is not a tool: it is a library of analyzer assemblies the host loads, so it is passed as a directory rather than installed. That directory sits in the NuGet cache because an analyzer package deliberately has no lib/ folder and is marked a development dependency — a PackageReference therefore puts nothing in your bin, and after a restore, the cache is where the assemblies live. --analyzers-path takes any folder holding them and searches it recursively.

The CLI only REPORTS; it never edits your files, which is what you want in CI (SARIF output works in GitHub code scanning). To apply the fixes, use our own tool below. Individual rules can be turned off per repository with a fsharprefactor.json — see Configuration below.

Applying fixes from the command line

The fsharp-refactor dotnet tool applies the quick fixes directly to your files:

dotnet tool install --global fsharp-refactor
fsharp-refactor Your.fsproj [--dry-run] [--codes FR0002,FR0031] [--api-changes] [--jobs 4] [--max-passes 5]

(or from this repository: dotnet run --project src/FSharp.Refactor.Tool -c Release -- Your.fsproj ...)

For a project it takes the exact compiler arguments from MSBuild; for a script FCS resolves the references itself and MSBuild never runs. Either way it then runs every analyzer, applies non-overlapping fixes bottom-up, and re-analyzes until a pass applies nothing — a fix can enable further fixes. It refuses a compilation that already has errors, and fails loudly if applying ever introduces one.

Flag
--dry-run Report only: lists every fix it would make, with file and position, and writes nothing. Rewriting is never implicit — drop the flag to let it edit.
--codes FR0002,FR0031 Restrict the run to chosen rules.
--categories <list> Restrict the run to kinds of rule: correctness, performance, idiom, cosmetic. Combined with --codes it narrows further, in either order. See Someone else's codebase.
--jobs <n> Typecheck that many files at once (default 4, clamped to 2–4 by core count). Trades CPU for wall clock; because FCS reuses each file's prefix within one incremental build, the gain peaks around 4 and reverses if pushed higher. --jobs 1 is the sequential sweep.
--framework <tfm> Analyse against this target framework instead of the narrowest one — see below.
--max-passes <n> Fix-then-reanalyze iterations (default 5).
--help The same list, from the tool itself (-h and /? also work).
--api-changes Also apply the cross-file fixes described below.
--no-if-defs Never emit #if/#else/#endif pairs for capability fixes on multi-targeted projects (see below). The fixes stay plain, and any the legacy frameworks reject are put back by the final build check.
--report <file> Write every finding the run surfaced to a file; the extension picks the format. .sarif (or .json) is SARIF 2.1.0 — what GitHub code scanning renders as inline PR annotations; .html is a self-contained page with per-rule grouping, highlighted source, before/after fixes and category filters; .csv is one row per finding for a spreadsheet. Pairs naturally with --dry-run for a CI lint gate. See CI setup below. A many-target run rewrites the report after every target, so a crash or a Ctrl-C an hour in still leaves what was found. Pointing the tool at a workspace of checkouts — a directory with no solution of its own whose sub-directories carry theirs, C:git say — analyses each checkout on its own (its solutions honoured, its fsharprefactor.json applied) into the one report, with paths relative to the workspace.
--baseline <sarif> The ratchet: findings whose fingerprints appear in this earlier --report output are neither reported nor fixed — only what is NEW surfaces. Fingerprints hash the rule code, file name and normalized surrounding source, so they survive line shifts, other edits in the file, and different checkouts. Triage once, ratchet forever.
--fail-on-findings Exit 3 when any finding survives the filters — the hard CI gate. The full exit contract: 0 clean, 1 analysis or apply failure, 2 usage error, 3 findings (only with this flag).
--notes [on|off|only] --notes (or --notes on) lists fix-less advisory notes inline; --notes only is the review pass described next. By default a run prints its FIXES — the product — and ends with one per-category note count (41 advisory note(s) held: …); SARIF (--report) and --format json always carry the notes in full, which is where CI and agents read them.
--notes only A review pass: every rule runs, only the findings WITHOUT a fix are listed inline, and nothing is written. That is the 37 advisory rules (an em dash in Rules.md's fix column) plus the cases where a fixing rule can only advise. fsharp-refactor src/Your.fsproj --notes only --report notes.html writes them as a page.
--format json Machine-readable stdout: progress prose moves to stderr and the run's findings leave as one JSON document (code, severity, fixable, position, message, fingerprint, source snippet). The default output stays human-readable.
--rules Print the rule catalog — code, category, enabled-by-default (honors --format json).
--create-config Write a fsharprefactor.json of this build's defaults — every rule, every run-level key, one comment each — into the current directory, or into <what> when that is a directory. It changes nothing until you edit it, and never overwrites an existing config.
--mcp Serve the tool as an MCP server over stdio (newline-delimited JSON-RPC, no extra dependencies): tools analyze (target, codes/categories, parseOnly, apply) and list_rules. One warm typechecker lives across calls, so the first analyze pays the reference parse and the rest answer from a hot cache — the economics agent loops need.
--parse-only For a codebase that cannot COMPILE on this machine — a type provider needing its database, references that cannot restore. No MSBuild, no reference resolution: sources come straight from the fsproj's <Compile> items, and only the 56 of 116 analyzers that never consult the typechecker run (the typed rules are excluded outright, not trusted to self-silence). It is not a substitute for a real run, and what survives is skewed the wrong way: measured across the corpus, roughly a quarter of the correctness rules and a quarter of the performance rules still fire, against three quarters of the cosmetic ones — so a clean --parse-only says very little, and says least about the things worth knowing. Findings lost run to 38% on a typed-heavy codebase and under 10% on one the cosmetic rules dominate. Safety shifts accordingly: instead of a build, the gate is that a pass must not RAISE the compilation's error count over its baseline, and the usual parse-level protections (comment guard, overlap holds) still apply. Limitations: #if branches behind conditional or computed DefineConstants are not parsed, wildcard <Compile> globs are refused, and multi-framework passes collapse to one. Review the diff — the all-frameworks build arbiter is exactly what this mode does without.

CI setup (SARIF)

A dry run plus --report gives CI the full findings list without touching a file; uploading the SARIF turns each finding into an inline annotation on the pull request. Paths in the report are relative to the repository that holds the target (the nearest .git above it), so the tool may run from anywhere; each result carries the rule's description and help link, the finding's own text with three lines of context, the fix as a SARIF fixes entry (code scanning renders it as a suggested change), a stable fingerprint, and the run's invocation record:

  refactor-lint:
    runs-on: ubuntu-latest
    permissions:
      security-events: write     # required by upload-sarif
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
      - run: dotnet tool install --global fsharp-refactor
      - run: fsharp-refactor src/Your.fsproj --dry-run --report findings.sarif
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: findings.sarif
          category: fsharp-refactor

Two honest notes. First, a dry run exits 0 whether or not it found anything — findings are hints, not errors, and only a broken build or a crashed run fails the step. Annotations therefore inform without blocking; to make findings HARD-fail the job, add an explicit check:

jq -e '.runs[0].results | length == 0' findings.sarif

Second, scope the gate before turning it on: --categories correctness,performance keeps the signal defensible on a shared repository (see Someone else's codebase), and a fsharprefactor.json turns off anything the team has decided against. The suppression comments described under Configuration silence individual findings at the line, for both the gate and editors, in one place.

Running alongside other analyzer packages

The fsharp-analyzers host loads every analyzer assembly it is pointed at, so one invocation — and one FCS typecheck, the expensive part — can run this package together with others built on the same SDK, all findings landing in one report. With G-Research's analyzers, whose current release pins the same FSharp.Analyzers.SDK as this package (0.37.2):

fsharp-analyzers --project src/Your.fsproj \
  --analyzers-path ~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs \
                   ~/.nuget/packages/g-research.fsharp.analyzers/<version>/analyzers/dotnet/fs \
  --code-root . --report findings.sarif

Rule codes are disjoint (FR* here, GRA* there), and the fsharpanalyzer: ignore-line suppression comments work for their codes too — the machinery lives in the shared SDK, not in any one package.

The SDK is strict about version agreement between the host and every analyzer assembly it loads; a mismatch means analyzers silently fail to load rather than erroring loudly. The day the two packages pin different SDK minors, fall back to separate CI jobs — each producing its own SARIF and uploading under its own category: (code scanning keeps the streams apart) — at the cost of typechecking the project once per job. Applying fixes stays this package's own tool either way: fsharp-refactor applies only its own rules, and report-only analyzers have nothing to collide with.

Someone else's codebase

Every rule is one of four kinds, shown in the last column of Refactorings:

Kind Count
correctness The code does something other than what it looks like it does: a race, a swallowed exception, a disposable that leaks, a comparison that never holds 53
performance Correct, but doing work it need not: allocations that need not happen, repeated work, a scan where a lookup would do 33
idiom The same behaviour written the way F# writes it. Worth doing, and worth agreeing on first — it is a matter of house style as much as anything 54
cosmetic The punctuation and spelling of code. Real cleanups, and nobody's idea of a welcome pull request from a stranger 17

Every version's notes are in CHANGELOG.md; the NuGet packages carry the last six.

Thirteen rules carry a priority flag on top of their category — the likely defects and security holes too costly to hold back: FR0020, FR0028, FR0032, FR0046, FR0047, FR0048, FR0061, FR0063, FR0065, FR0066, FR0122, FR0126 and FR0127. Swallowed exceptions (FR0055) and public mutables (FR0062) stay plain correctness: bad habits more often than live defects. Their notes print without --notes, editors show them as warnings, and SARIF carries them at warning level. Rules.md marks them in its Priority column.

This matters when the repository is not yours. Running everything over a project you do not maintain and opening a pull request from the result is a good way to waste an afternoon of someone's life: no maintainer wants "removed an empty attribute argument list" across two hundred files, and a diff that size buries anything that mattered. A disposable that is never disposed is a different conversation entirely.

So for a codebase you are a guest in:

fsharp-refactor Their.fsproj --categories correctness,performance --dry-run

That is the set that earns its review time. For your own code, run the lot.

The category claims are measured, not assumed:

dotnet run -c Release --project benchmarks/PerfClaims

re-checks them on your machine, on BOTH axes — wall clock and allocation (GC pressure is performance too). The contract: a performance rule's rewrite must win on at least one axis, and an idiom rule's must hold parity. FR0050 once emitted Seq.sum for a list — ~50%% slower than the mutable loop it replaced, plus an enumerator allocation — which is how the benchmark file, the rule's module-resolved output, and its idiom recategorization all came to exist.

Multi-targeted projects

Nothing extra to do: a multi-targeted project is worked through framework by framework, narrowest first.

Capability fixes get both worlds — using the project's own vocabulary. When a project also targets frameworks older than an overload (net4x, netstandard2.0), the tool reads the fsproj's DefineConstants and looks for a framework-shaped constant — NETSTANDARD21, NET8, digits required — whose '$(TargetFramework)' == '...' conditions cover only the modern frameworks. Names denoting a legacy framework (NET48, NET451, NETSTANDARD2_0) are refused outright whatever their conditions say: the SDK defines exactly those constants during the legacy compilations themselves, where no fsproj parse can see them. Flavor names sharing the same condition (SQLProvider defines MICROSOFTSQL right beside NETSTANDARD21) are passed over: their meaning is the flavor, and a sibling project compiling the same shared file may define them on legacy frameworks too. If a constant qualifies and the file already uses conditional compilation, FR0038 and FR0106 emit a pair instead of a fix the legacy half cannot compile:

#if NETSTANDARD21
let orderNumber (s: string) = Int32.Parse(s.AsSpan(6, 5))
#else
let orderNumber (s: string) = Int32.Parse(s.Substring(6, 5))
#endif

No invented constants, ever: a project defining no such constant gets the plain fix, and the final all-frameworks build stays the arbiter (a fix the legacy half rejects is put back). Constants appearing in a DefineConstants element whose condition the tool cannot fully read (anything beyond '$(TargetFramework)' == 'X' chained with Or) are disqualified rather than guessed at. A line already inside a positive region of the chosen constant — or a hand-written NET*_OR_GREATER — gets the plain fix (nothing legacy compiles it), a file with no #if anywhere stays free of them, and editors always suggest the plain form. --no-if-defs turns the pairing off entirely for a run that should never add conditional compilation, whatever the project defines.

Large solutions stay affordable through three levers: one FCS checker serves the whole run (twenty projects share nearly all their reference assemblies, parsed once); a shared source file swept under one set of conditional-compilation defines is never re-swept by the next project that compiles it identically; and a multi-targeted project whose sources contain no #if at all gets a single-framework sweep, with the final all-frameworks build still verifying the rest.

That is not busywork. A rule gated on what the target can resolve behaves differently per framework — s.Contains 'x' is offered under net8.0, where the char overload exists, and does not compile for a netstandard2.0 target that lacks it. And each framework activates its own #if branches, so code behind another one's is not in the parse tree at all. One pass could only ever see part of the code.

Narrowest first means the fixes valid everywhere land before any that suit only a wider surface, and every pass ends by building all the frameworks, so a fix that does not generalise fails loudly instead of passing as success.

Given this, one plain fsharp-refactor Your.fsproj produces:

let has (s: string) =
#if NETSTANDARD2_0
    s.Contains "x"      // still a string: no char overload here
#else
    s.Contains 'x'      // rewritten under the net8.0 pass
#endif

--framework <tfm> restricts a run to one framework if you want it.

Allow changes to public API like types

Public types and function signature changes are not done by default. Sometimes they would make the program more efficient:

Changing type Item = { X: Option System.Guid } to type Item = { X: VOption System.Guid } would often make sense because Guid is already a struct, so ValueOption is better here. But that could affect to external users and serialization.

--api-changes opts into rewrites that change internal or public signatures — currying a tupled function (FR0090) and reordering its parameters data-last (FR0091) — rewriting every call site in the project, in the scripts that #load it or #r its built assembly, and in the sibling projects of the same solution that reference it (the test project, typically) or compile one of its sources directly (a linked file; such a fix line says note: linked file). Without it those are held back and only counted. It also widens the contained-type hints (FR0022, FR0069, FR0070, FR0093) to public types. Consumers outside the run are why this is opt-in: their call sites cannot be rewritten, so a public function changes shape only when every project referencing it in the run is an F# project that typechecks — a referencing C# project, a sibling with errors, a #r script that does not typecheck against the sources, or a bare project with no solution above it holds the public surface as it is, and the run says so - and each rule only fires where a call site it still cannot see would fail to compile rather than change behaviour silently. Naming a single source file skips these entirely — asking for one file and getting edits in its callers would be a surprise.

The flag bundles two separable things: fixes that edit OTHER files, and the widening of in-place shape changes to public declarations. Only the first needs asking for: an assembly nothing links against — an executable, a script — gets the second by itself, and a library opts in with "publicApi": false in fsharprefactor.json. Either way it reaches the editors, where the flag never has. See Is your public surface an API?.

Refactorings

Every rule lives in Rules.md: a one-line table for scanning — what each fires on, the fix it offers, whether it is on by default, whether its fix needs --api-changes — followed by a section per rule with the full reasoning. The tests keep that file and the catalog in step.

Roadmap based on "F# refactoring possibilities".

Configuration

Rules can be disabled per repository with an optional fsharprefactor.json, searched upward from each analyzed file, stopping at the repository root (the nearest file wins). Keys are rule codes or analyzer names, case-insensitive; a malformed file fails open so it can never break the editor. Comments and trailing commas are tolerated.

fsharp-refactor --create-config writes one for you: every rule this build knows at its current default, every run-level key at its own default, one comment each. Nothing in it changes anything until you edit a line — flip what you disagree with, delete the rest to keep following the defaults as they change. It refuses to overwrite an existing config.

{
  "rules": {
    "FR0003": false,
    "conversionMove": { "enabled": false }
  }
}

Rules with tunable thresholds read numeric properties from the same object-valued entries. FR0114 takes thenAtLeast (default 20), how long a then-branch must be before flipping is suggested, and elseAtMost (default 3), how short the else must stay:

{
  "rules": {
    "FR0114": { "enabled": true, "thenAtLeast": 30, "elseAtMost": 2 }
  }
}

FR0060 takes maxAttributes (default 4), how many attributes may share one [<A; B>] bracket, and wrapColumn (default 110), how wide the merged line may get. Both are house style rather than correctness, and the rule simply declines to merge past either limit:

{
  "rules": {
    "FR0060": { "enabled": true, "maxAttributes": 6, "wrapColumn": 120 }
  }
}

A few entries are on/off switches rather than thresholds, and read a JSON bool as happily as 1. FR0029 takes tailLines (default 40), how many non-awaiting lines after the last await earn a tail extraction on a task the compiler did NOT warn about — where FS3511 names the task, the apply tool reads that off the build and the extraction is offered regardless — and hoistReturnOnAsync (default false), which extends just the return hoist, not the FS3511 advice, to async { }:

{
  "rules": {
    "FR0029": { "tailLines": 25, "hoistReturnOnAsync": true }
  }
}

FR0065 takes dropLegacyProtocols (default false). Retiring Ssl3/Tls/ Tls11 changes what the process negotiates with a remote endpoint, so it is an editor offer by default; setting this lets an unattended run comment the dead protocol out of the flags. --api-changes deliberately does not grant it — that flag is about callers needing a recompile, which is a different risk:

{
  "rules": {
    "FR0065": { "dropLegacyProtocols": true }
  }
}

Paths can be excluded too — additively over the built-in defaults (paket-files, .paket, node_modules), which cover generated and vendored code a compilation nonetheless includes:

{
  "ignorePaths": [ "generated", "external/imported" ]
}

A bare name matches as a whole path segment; an entry containing a slash matches anywhere in the normalized path; an entry containing * is a glob — * stays within a segment, ** crosses them (*.g.fs, src/generated/**). Ignored files are neither analyzed nor even type-checked by the apply tool's sweep — on a paket-heavy solution that is a lot of vendored source nobody wants "fixed". Files opening with the conventional // <auto-generated> marker are skipped automatically wherever they sit, as is everything under obj/.

Individual findings can be silenced in place with the F# analyzer SDK's own suppression comments — the same ones editors honor, so one comment silences both the light bulb and the apply tool (a suppressed finding is neither reported nor fixed):

// fsharpanalyzer: ignore-line-next FR0106
let orderNumber (s: string) = Int32.Parse(s.Substring(6, 5))

let inline dodgy (s: string) = s.Substring(0, 3) // fsharpanalyzer: ignore-line FR0106

// fsharpanalyzer: ignore-file FR0031, FR0038
// fsharpanalyzer: ignore-region-start FR0002
// fsharpanalyzer: ignore-region-end

Suppression comments are also easy to reach for, and a team may not want a correctness finding silenceable with one line of punctuation the way a naming nit is. The "suppressions" policy draws that line:

{ "suppressions": "no-correctness" }
  • "all" (default) — every suppression comment silences its finding.
  • "no-correctness" — comments on correctness-category rules are reported anyway; idiom, cosmetic, and performance suppressions still work. An overridden finding is never auto-FIXED — the tool does not rewrite code over someone's explicit comment — it is reported (and fails --fail-on-findings) until addressed or the comment is judged worth honoring.
  • "none" — every suppression comment is reported anyway.

--honor-suppressions on the command line overrides the policy to "all" for that run. A repo that wants suppressions inert on developer machines but honored by the pipeline commits "no-correctness" (or "none") in its config and passes the flag in CI only. Whatever the policy, the run summary counts what comments silenced — suppression is never silent. Note the policy only governs this tool: editors honor the SDK's comments natively, so the light bulb stays silenceable regardless.

Is your public surface an API?

Two of the config's keys decide how much of --api-changes applies without the flag, and they are worth keeping apart, because they gate two different risks.

{ "publicApi": false }

F# makes a declaration public by default, so public in a parse tree is usually the absence of a decision rather than one. The scope-gated rules — the ones whose fix changes a declaration's compiled SHAPE in place, [<Struct>], [<Literal>], named union fields, a field's option becoming voption — hold back on public declarations because a consumer in another assembly would see the change and nothing here can check it. "publicApi": false says there is no such consumer: an application, an internal tool, a leaf project. Those rules then treat public as internal, in the apply tool AND in the editors, where --api-changes has never been reachable. It licenses no edit outside the file being analysed.

With no setting, the compilation answers. An OutputType of Exe or WinExe has no external linker — nothing can reference its public declarations — so it is read as a leaf and the gate opens by itself. A library is not, and stays closed. Write "publicApi": true to overrule that: an executable that serializes its own public types, or loads plugins by reflection, wants the conservative behaviour back.

Scripts are answered file by file rather than as a whole compilation. A .fsx is the ultimate leaf — it links to nothing and nothing links to it — so its own declarations are in scope. What it #loads is not: that source belongs to whatever project owns it, quite possibly a library, and a script reading it says nothing about who else compiles it.

The scripts that get a vote are the .fsx under the solution or project folders being run, and their subfolders - not the whole drive, and not a path ignorePaths excludes: a path this repository has told the tool to ignore is external code, and external code does not decide how this repository's declarations are shaped. So the guarantee is that no script INSIDE the tree this run is responsible for is left calling a name that moved.

{ "apiChanges": true }

The other risk: a fix that must edit OTHER FILES — currying a function (FR0090) or reordering its parameters (FR0091) and rewriting every call site in the project. This is --api-changes as a standing decision, for a repository where it is always the right answer; it covers everything the flag does and so implies publicApi: false. A run started with the flag is unaffected, and the config can only ever widen, never take the flag away. The run says so when it picks the setting up.

A companion .fsi still wins over both: a signature file is the author's own statement of what is exported — including the val private it is free to write — so a shape change beside one stands down for any name the signature declares. And FR0092 (constant failwith messages) is not a visibility rule at all — its risk is a test or a caller reading the text — so it stays behind the flag alone.

What the gate holds back is reported, not hidden. A run ends with, say

  7 finding(s) held back by scope: 4 FR0070, 3 FR0022 — public declarations
  this run may not reshape. Set "publicApi": false in fsharprefactor.json if
  nothing outside this assembly links to them or serializes them.

so the decision is a decision, not something to guess at. Answer it once and the tool applies all of them; nobody should be retyping by hand what the tool could have written.

Editors offer them anyway, with the caveat attached. A light bulb is per-site consent from the one person who can actually answer the question, and it costs a click rather than a manual edit, so in an editor these findings appear on public declarations too:

Union 'Shape' holds only small value types; [<Struct>] avoids a heap allocation per value. CHANGES THE PUBLIC SHAPE: safe only if nothing outside this assembly links to it or serializes it (JSON, XML, protobuf — the tool cannot tell).

That last clause is not modesty. Serialization cannot be detected: System.Text.Json, Newtonsoft, XmlSerializer, DataContract, protobuf, MessagePack and whatever a consumer wired up by reflection all read the compiled shape, and a guard that enumerated some of them would break the rest silently. So the tool never infers that a shape change is safe to serialize — it says what changes and leaves the judgement to the reader. The apply tool, having nobody to ask, only counts them.

A disabled rule skips its analysis entirely, so the file also works as a performance lever on large codebases. Internally all analyzers share one memoized AST traversal per file version, so the editor pays for a single walk per keystroke regardless of how many rules are active.

Every rule defaults to enabled except two:

  • FR0099 (line-ending semicolons) lexes every file containing one and rarely finds anything — cost out of proportion to a cosmetic default.
  • FR0002 (match option → Option combinators) is the one measured rewrite that makes YOUR code slower — +53% and a closure allocation per call on its benchmark pair. Nice to read, costs to run; opt in when that trade suits the codebase.

Turn either on with "FR0099": true / "FR0002": true, or ask explicitly: the apply tool treats --codes FR0002 as outranking both the default-off status and a config disable — naming a rule is an ask. A --categories filter deliberately is not one: --categories idiom runs the idiom rules that are on, and does not quietly wake the default-off ones.

The same file can add custom FR0012 term-rewriting rules using FSharpLint's hint syntax (single-letter identifiers are metavariables):

{
  "hints": {
    "add": [
      "Option.isSome x |> not ===> Option.isNone x"
    ]
  }
}

Custom rules get the same safety treatment as the built-ins: bindings are parenthesized as needed, and a rule whose right side drops or duplicates a metavariable only fires on pure atoms (never discarding a side effect).


Improving it

Contributions welcome. This section is for working ON the analyzers; everything above is for using them.

Trying your changes

Build the analyzers, then point either host at the build output instead of the NuGet cache.

In an editor, via the target repo's .vscode/settings.json:

{
  "FSharp.enableAnalyzers": true,
  "FSharp.analyzersPath": ["<path-to>/FSharp.Refactor.Analyzers/bin/Debug/net8.0"]
}

Open an F# file containing e.g. match x with | true -> 1 | false -> 2 — a hint appears offering if x then 1 else 2.

Or from the CLI:

fsharp-analyzers --project YourProject.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .

Note: analyzers must be built against an FSharp.Compiler.Service compatible with the host FsAutoComplete. This project currently pins FSharp.Analyzers.SDK 0.37.2 (FCS 43.12.201). See the SDK's version-pairing table when updating.

Building and testing

dotnet build
dotnet test

This project eats its own dog food. Before committing:

dotnet tool restore
dotnet fantomas src tests
dotnet dotnet-fsharplint lint src/FSharp.Refactor.Analyzers/FSharp.Refactor.Analyzers.fsproj
dotnet dotnet-fsharplint lint src/FSharp.Refactor.Tool/FSharp.Refactor.Tool.fsproj
dotnet dotnet-fsharplint lint tests/FSharp.Refactor.Tests/FSharp.Refactor.Tests.fsproj

and the analyzers are run against their own source, expecting zero findings. Both projects, not just the analyzers — the apply tool is F# we ship too, and it went a long time unchecked:

dotnet tool run fsharp-analyzers --project src/FSharp.Refactor.Analyzers/FSharp.Refactor.Analyzers.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .
dotnet tool run fsharp-analyzers --project src/FSharp.Refactor.Tool/FSharp.Refactor.Tool.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .

Test inputs are string literals, so formatting tools never touch the deliberately-shaped source fragments the tests exercise.

Design principles

  1. Never break user code. A fix is only offered when it is provably safe to apply; borderline cases simply don't produce a suggestion. Fixes are minimal range-based text edits applied by the editor, so they are always a single native undo step.
  2. Minimal edits. Original formatting outside the edited range is untouched — no whole-file reformatting.
  3. Pure core, thin adapters. Each refactoring is a pure function ParsedInput -> ISourceText -> Suggestion list, unit-tested directly against source strings. The SDK analyzer entry points in Analyzers.fs are one-liners.
  4. Hints point toward idiomatic F# only. Every analyzer rewrites a → b where b is the more idiomatic form; we never ship a hint that moves code away from idiomatic F#. That is why suggestions are Hint severity, not warnings: they mark an opportunity, not a defect, and they never gate CI. Genuinely reversible rewrites where neither direction is more idiomatic (if ↔ match, tupled ↔ curried) belong in FsAutoComplete's codefix infrastructure as user-invoked refactor.rewrite actions, and should be contributed there rather than here.

Our Vision, and Other projects in the same field

AI Agent compatibility: This project does distinct the F# code from generated Python smell. Meanwhile, some past rules (like function length and cyclomatic complexity) are expected to gains less attention in the future. This project has focus on idiomatic F#, code performance and best practices, and less interest on code structure/naming/maintainability.

This project aims to be compatible with other products, so you won't end-up having oscillation/fight between suggested changes.

Tool Same rules Status
FxCop and MS Code Analysis Many We have implemented the MinimumRecommendedRules, and some performance etc. rules relevant to F#
FSharpLint Many Instead of just listing, we have quick-fixes and auto-fix. Rules are compatible with this project.
Resharper F# Many Have many same features, meanwhile using totally different AST.
Resharper C# Partial Resharper has heavy focus on OO meanwhile we focus on FP. Many C# issues don't exist in F# at all (like clojure captures, etc.).
Linq.Expression.Optimizer Some We optimize compile-time, meanwhile this tool optimize runtime-code
SonarQube Minor Most of SonarQube rules are opinionated enterprise development rules ported from Java. But we have some of the same .NET relevant rules.
G-Research FSharp Analyzers Not really Good rules to focus maintainability. Different focus. Should work well together.
Fantomas None Different focus: Fantomas is a code layout tool. We are compatible so you can use both.
FSharp.Analyzers.SDK None Our tool, fsharp-refactor, is built on FSharp.Analyzers.SDK
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.8.25 0 9/19/2026
0.8.24 0 9/19/2026
0.8.23 35 9/18/2026
0.8.22 44 9/18/2026
0.8.21 46 9/17/2026
0.8.20 59 9/17/2026
0.8.15 56 9/15/2026
0.8.14 84 9/14/2026
0.8.12 86 9/13/2026
0.8.11 88 9/11/2026
0.8.10 90 9/10/2026
0.8.8 99 9/10/2026
0.8.7 90 9/8/2026
0.8.6 93 9/8/2026
0.8.5 96 9/7/2026
0.8.4 100 9/6/2026
0.8.3 98 9/5/2026
0.8.2 98 9/4/2026
0.8.1 99 9/3/2026
Loading failed

0.8.22: FR0157's serializer guard reads the VALUE handed to a reflective head, not only a type argument: a record, or a collection of one, passed or piped to `JsonSerializer.Serialize`, behind a field or property (`Serialize wrapper.Items`) or anywhere along a curried call (`Serialize options value`), stands the rule down, since every field of it is read by reflection and a union field would serialize differently. The union a run adds carries the widest visibility of the slots it types - private when they are all private, internal when one is internal or the component spans files, public only when a public one is among them - so a run without --api-changes never adds a public type to a library by itself. Two audit tests that assumed Windows path semantics now pass on Linux too.
0.8.21: Three defects from the 0.8.20 sweep over thirty-five repositories. The api pass no longer puts cross-project edits back on a false alarm: FCS cannot hand a sibling in-memory assembly data for a project whose typecheck creates generated provided types (a JsonProvider sample), so the sibling silently bound to the dll on disk, built before the edits, and every migrated call site failed against the old signature (CarmelNet's tests); a failed in-memory check is now confirmed by a real build of the sibling before anything is put back, and the referenced project is rebuilt so the next pass reads the sibling against the edits. FR0156 keeps a pair of parentheses where the drain's were an application's own - `Some(List.ofSeq acc)` had become `Someacc`. FR0157 never gives two unions in one file the same name: two records with a `domain` field each got a `Domain`; a field's second choice now carries its record's name (`JobKind`) before the `Kind` suffix.
0.8.20: Three idiom rules. FR0158 (new) turns a while loop that walks a mutable index while a condition holds - `let mutable line = 0` then `while line < max && blank line do line <- line + 1`, or the countdown - into a tail-recursive local function (`advanceLine` / `retreatLine`) and an immutable `let`; measured level with the loop, where the Seq.tryFind spelling doubled the time and allocated per call. FR0156 (new) turns a ResizeArray that loops fill one Add at a time and the rest of the scope only reads into a list expression: the loops (for and while alike) move inside the brackets as they are - guards, matches, nested loops, lets and the statements around each Add included - and each Add becomes the implicit yield of its argument; List.ofSeq acc becomes acc, and Seq functions, for x in acc and an upcast to seq keep working. Measured in PerfClaims (.NET 10, 1000 ints): 20 percent faster on 30 percent less allocation than the fill-then-copy. A drain that wants an array (an index, Count, ToArray(), Array.ofSeq) stands the rule down, since the array expression measured 1.6x the ResizeArray fill's time and ResizeArray plus ToArray() is the fastest spelling of an array built one element at a time; { "FR0156": { "arrays": true } } buys the shape at that price. The declared element type becomes the list's annotation (Add converted an int literal to int64 through the method call where a bare yield would not), and an element type with subtypes to upcast from (an interface, obj, an unsealed class) stands the rule down. FR0157 (new) turns a closed set of string literals matched by name into a [<RequireQualifiedAccess>] union with a ToString returning the original text. The proof is a flow analysis over the string slots the value passes through - a parameter, a let, a record field, a function's return, a name a pattern binds, plain or option-wrapped - closed when every source is a literal (or a constant, [<Literal>] or not) or another slot and every sink a match, a comparison with a literal, a flow into a slot or a print through ToString; proven from either end, the call sites or a producing function whose every exit is a literal. Every literal source, arm and comparison becomes its case, every annotated slot is retyped, and a catch-all the proof leaves dead is dropped - it stays where the proof found a literal no arm names. A variable pattern spelling a module-level constant's name beside later arms (| us -> under let us = "...", which binds a fresh us and matches everything) is taken for the comparison the author meant, and the message says so. A Result<_, string> is a slot like the others: its Error "..." exits are sources, its Error "..." arms the consumer, the Ok side is not followed. Under --api-changes the apply tool's api pass runs the rule with every referencing project's call sites in its world, so an exported function's literal callers count; where the host still cannot see every caller, the exported function keeps its public signature behind an adapter - a private twin typed with the union, the compilation's own callers moved to it, a wrapper of the old name mapping at the edge (OfString, whose unknown arm is the match's own raising catch-all; Result.mapError string) under a TODO. A %s hole becomes %O and a concatenation operand `string x`; a sentence-shaped literal keeps its words behind double backticks; a record a serializer fills ([<CLIMutable>], a Json or DataContract attribute, the type as a type argument anywhere), a dynamic message, a method call on the string, a function used as a value, a lambda parameter, and a set no producer feeds stand the rule down. The apply tool reads the whole compilation's symbol uses once, indexed by declaration and by position, so the analysis costs a few seconds per project rather than a walk per question. FR0154 is a correctness rule now and rewrites only the miss arm: TryGetValue then a store on a ConcurrentDictionary keeps its match and hit arm, and the miss arm becomes GetOrAdd(key, fun _ -> compute ()), whose add is atomic. Measured at tier 1 the hit path - the one a cache takes almost every time - is 2.1 ns and nothing on TryGetValue against 7.3 ns and a 64 B delegate on GetOrAdd, so the whole-match rewrite of 0.8.12 was a slowdown sold as a speedup; the race it closes is the reason, and this shape closes it at no cost. Where running the factory ONCE matters, GetOrAdd alone does not buy it (concurrent misses each run it), so a factory that calls something carries the hint that a Lazy value - GetOrAdd(key, fun _ -> lazy (compute ())).Force() - runs it once, and a Task, ValueTask or Async value, where the discarded result is a computation already running, gets that note without a fix.

0.8.19: The build configuration the analysis does not see is no longer a blind spot. A project whose sources branch on `#if DEBUG` / `!DEBUG` / `RELEASE` / `TRACE` gets a second pass under the other configuration, where those branches are the parse tree, and its verification builds both configurations; a referencing project edited by a call-site migration is built in the other configuration too, and the migration is put back when that build fails. A call-site migration (FR0090, FR0091) keeps back a function named inside a configuration region, whose other branch has calls no parse tree holds, and one a string literal names anywhere in the project or its referencing projects - a code generator's template writes calls no symbol table lists (SQLProvider.Fable's CodeGen kept emitting the old parameter order after FR0091 reordered Row.text). The apply tool withholds any multi-line fix whose span covers a #if, #else or #endif line, whatever rule offered it.

0.8.18: A sweep spends far less of its time in MSBuild and FCS. The compiler-argument query no longer cleans the project (a design-time build with the target forced, in place of the Rebuild that cleaned the outputs and cost a third build to put them back), and the FS3511 harvest that made every compilation recompile is cached per source tree under the local application data, so an unchanged project builds incrementally in a second or two - 28 seconds of fsc per compilation on FunStripe.Core gone. A multi-targeted project builds every framework in one parallel outer build on its narrowest pass, and the next framework's compiler arguments and typecheck run ahead on a checker of its own while the narrower pass sweeps: FCS keeps one incremental builder per project file name, so the frameworks were throwing each other's typecheck away at every switch and could never share one; the second framework's check now costs milliseconds. A file whose only conditional directives are INTERACTIVE or COMPILED - the idiom for a source that also serves as a #load'ed script - is swept once across frameworks like a directive-free file, since neither symbol varies between a project's frameworks. A script's sweep leaves the #load'ed sources a project compiles to that project: Owin.Compression's Script.fsx, typechecked as .NET Core, had written Convert.ToHexString and File.ReadAllBytesAsync into the net48 project's CompressionModule.fs. A proposed FR0155 ([<Sealed>] on internal classes) is benchmarked in PerfClaims before being written: the covariant array store halves and the type test does better, the virtual call does not move.

0.8.17: A test project's declarations reshape without --api-changes: a project that references xunit, NUnit, MSTest or Expecto exports no API, nothing links to it, so FR0090, FR0091, FR0069, FR0093 and FR0049 treat its own functions as they treat a private module's, while the library compiled after it finds the gate closed again. The project's own type providers are instantiated before any `#r` script is read against a .NET Framework reference set: FCS keeps a design-time assembly by name, welendus's Program.fsx `#I`d packages/FSharp.Data/lib/net45, and every project's JsonProvider was "not defined in 'FSharp.Data'" from then on - four projects skipped in a tree that builds. FR0147 checks every bare name the file uses against what an open brings, not only the uppercase ones: an `[<AutoOpen>]` module brings its VALUES with the namespace, and `open Lib` placed below `open Other.Z` rebound a `helper 4` from Z's to Lib.Auto's, compiling and answering differently; a name resolving to a local is no clash (a local shadows the open wherever it sits); and a union case matched by its qualified spelling (`| Lib.Kind.A ->`) is shortened with the expressions, when it resolves to a case. FR0094 keeps the parentheses inside a `_.` shorthand lambda, as FR0013 did: `configureEndpoint _.WithName("x").WithGroupName(g)` bare applied the lambda to `g`. FR0029 leaves a `return (* note *) value` in place for the plain closure: without the keyword the line's first token stands right of the comment, and the parser reads it as the previous let's continuation. FR0035 asks the typed tree whether a list's element type satisfies `comparison` before turning the list into a `Set`, and offers the HashSet companion (equality only, which `List.contains` already asked) where it does not - a [<NoComparison>] record, one with a function field. A rewrite that fabricates a closure or a computation expression asks the typed tree whether the code it moves reads a byref or byref-like value - a Span, a ReadOnlySpan, a [<IsByRefLike>] struct of the project&apos;s own - which no closure may capture and no task { } may hold: FR0034&apos;s Option.exists lambda, FR0009&apos;s Result lambdas, FR0029&apos;s runTail, FR0142&apos;s task-returning test and FR0049&apos;s taskify all stand down where one is in reach (FR0154 already did; the check is shared now). FR0142 no longer holds a test back for a `static let mutable` of its OWN class: xUnit, NUnit and MSTest run one class&apos;s tests one after another whatever they return, so the order CarmelNet&apos;s tests rely on - one writes a payment id, the next reads it - survives the rewrite; module-level state and another class&apos;s still do. A typecheck that never returns is abandoned after 15 minutes (FSREF_CHECK_MINUTES raises it) and the compilation reported and skipped rather than the run waited on: SQLProvider&apos;s DuckDbTest.fsx sat in its type provider&apos;s database connection for two and a half hours.

Earlier versions: CHANGELOG.md in the repository (https://github.com/Thorium/fsharp-refactor/blob/main/CHANGELOG.md).