FsHotWatch 0.10.0-alpha.3
dotnet add package FsHotWatch --version 0.10.0-alpha.3
NuGet\Install-Package FsHotWatch -Version 0.10.0-alpha.3
<PackageReference Include="FsHotWatch" Version="0.10.0-alpha.3" />
<PackageVersion Include="FsHotWatch" Version="0.10.0-alpha.3" />
<PackageReference Include="FsHotWatch" />
paket add FsHotWatch --version 0.10.0-alpha.3
#r "nuget: FsHotWatch, 0.10.0-alpha.3"
#:package FsHotWatch@0.10.0-alpha.3
#addin nuget:?package=FsHotWatch&version=0.10.0-alpha.3&prerelease
#tool nuget:?package=FsHotWatch&version=0.10.0-alpha.3&prerelease
FsHotWatch
Trying to speed up the F# development feedback loop.
FsHotWatch is a background daemon that watches your source files and aims to keep the F# compiler warm, so saving a file re-checks just what changed and hands the results to your tools (linters, analyzers, test runners) — instead of each tool restarting the compiler from scratch every time.
Status: early alpha, and a lot of it is AI-written. It runs the author's own daily F# work, but behavior and APIs shift between versions, rough edges are expected, and your mileage may vary. The goal is a faster F# loop — it's still finding its shape, so issues and PRs are very welcome.
The problem
F# tools are slow because each one starts its own compiler from zero. A 15-project solution can take ~2 minutes to analyze. Every save restarts your linter, your analyzer, and your test runner — all re-parsing and type-checking the same hundreds of files again.
How it works
FsHotWatch runs one compiler in the background and shares it with all your tools:
- You save a file — FsHotWatch notices.
- It re-checks just that file using the already-warm compiler — ideally milliseconds rather than minutes.
- Plugins get the results instantly — your linter, analyzer, and test runner see the new check results without re-parsing.
- You query the results —
fshw checkruns every plugin and reports what each one found.
Saves are debounced: if 10 files change at once (a formatter sweeping the repo, say), FsHotWatch waits for things to settle and processes them in one batch.
Quick start
# Install the CLI
dotnet tool install -g FsHotWatch.Cli
# The inner loop. Auto-starts the daemon the first time —
# no separate "start" step needed. Verbose by default.
fshw check
# Prefer one line per plugin?
fshw check --compact # or -q
# Before you merge: run the full suite and confirm `check` told the truth.
fshw confirm
fshw init writes a starter .fshw.json; see Configuration.
check, confirm, verdict
Three verbs, and the difference between them is the difference between a fast answer and a trustworthy one.
fshw check is the inner loop, and it is not a merge decision. It runs
every plugin (build, lint, analyze, test, format-check), but the tests are
impact-filtered — it runs the tests a heuristic selector thinks your change
can affect. That is a latency optimization, and it is the right trade for the
loop you run on every save.
fshw confirm is the merge verb. It runs the same checks with the tests
unfiltered, and it refuses to go green unless they actually ran that way —
"nothing failed" is not a verdict if the run never produced the evidence
(exit 3). Running it beside check is a comparison, and every disagreement
is a bug in one of them:
| What you see | What it means |
|---|---|
Failed under confirm, never selected by check |
The selector MISSED a test. An impact-analysis bug, not a test bug. |
Passed under confirm, but check says red |
A stale ledger, a flake, or a test-isolation defect — a test that only passes with company. Here check is the honest one. |
fshw verdict reads the answer back, from a file, without contacting the
daemon — see Machine-readable state.
What these verbs do not claim
This matters more than the feature list, so it is stated up front:
- "The full suite" means every test project your
.fshw.jsonknows about — not every test project in your solution. A project that is in the solution but absent from.fshw.jsonis not run byconfirm, andconfirmdoes not claim otherwise. - Impact selection is still known-unsound.
confirmmakes the merge verdict unforgeable — a green must be backed by an unfiltered run. It does not make the selector sound:checkcan still miss tests it should have picked. Those are two different claims and only the first one is fixed. - A warm cache makes
confirmexit 3, not 0. Re-run it on an unchanged tree and the test plugin replays its cached result rather than running; a replay is not evidence, soconfirmrefuses (exit 3) instead of greening. Clear the cache (rm -rf .fshw/cache) or change a file. It fails safe, but it is a rough edge.
Commands
| Command | What it does |
|---|---|
fshw check |
The inner loop. Run every plugin and report findings; tests are impact-filtered. Auto-starts the daemon. Exits 0 (clean), 1 (failures), 2 (completeness unconfirmed). --run-once runs without a daemon; -q/--compact for one line per plugin. |
fshw confirm |
The merge verb. Same checks, but the tests run unfiltered — and a green is refused unless they did. Exits 0/1/2 as check, plus 3 (unearned scope). --run-once for CI. |
fshw verdict |
Read .fshw/verdict.json and report whether it still applies to the tree on disk. Contacts no daemon, triggers no run. |
fshw status [plugin] |
Show the daemon's current status (optionally for one plugin). Triggers nothing. |
fshw start |
Run the daemon in the foreground (Ctrl+C to stop). Optional — check/status start it for you. |
fshw stop |
Stop the running daemon. |
fshw format |
Format the code (Fantomas). |
fshw test-rerun |
Rerun tests for an xUnit v3 --filter-class / --filter-trait slice. |
fshw rerun <plugin> |
Force one plugin to re-run, clearing its cached state. |
fshw init |
Generate a starter .fshw.json. |
fshw config check |
Validate .fshw.json without starting the daemon. |
Add -v for debug logging or -a for agent-friendly, parseable output. Run
fshw --help for the full list, and see the
CLI README for every verb and flag.
Machine-readable state (for agents and CI)
Don't parse the CLI's output. It is a progress display written for a human and
it will change. Every check and confirm publishes its result as a file:
fshw verdict # stdout: a JSON envelope; exit code: the answer
# 0 green · 1 red · 2 incomplete · 3 unearned scope · 4 STALE · 5 no verdict
.fshw/verdict.json is written atomically at the end of every run — including
the ones that fail, time out, or lose the daemon mid-run, which are exactly the
moments the human output is least sufficient. It is content-addressed to the tree
it verified and the binary that verified it, so a green from a different tree
(or from an older, buggier fshw) can never be mistaken for a current one. Reading
it opens no socket and starts nothing, so asking cannot perturb the answer.
This — not the progress display — is the surface agents and CI should read. Full schema, exit codes, and the tree-hash recipe: CLI README and ADR-013.
Packages
FsHotWatch is split into small packages so you install only what you need:
| Package | What it does |
|---|---|
FsHotWatch |
Core library — the daemon, file watcher, plugin system, IPC |
FsHotWatch.Cli |
CLI tool — fshw check, start, stop, status, … |
FsHotWatch.TestPrune |
Plugin: figures out which tests to run when code changes |
FsHotWatch.Analyzers |
Plugin: runs F# analyzers (like G-Research or your own) |
FsHotWatch.Lint |
Plugin: runs FSharpLint using the warm compiler's results |
FsHotWatch.Fantomas |
Plugin: checks if your files are formatted with Fantomas |
FsHotWatch.Build |
Plugin: runs dotnet build and emits BuildCompleted events |
FsHotWatch.FileCommand |
Plugin: runs custom commands when specific files change |
FsHotWatch.Coverage |
Plugin: checks per-file line/branch coverage thresholds after each test run |
Configuration
Run fshw init to scaffold a .fshw.json in your repo root, or write one by
hand. Every field is optional — sensible defaults apply when omitted.
{
"build": {
"command": "dotnet",
"args": "build"
},
"format": true,
"lint": true,
"cache": "file",
"tests": {
"beforeRun": "dotnet build",
"dependsOn": ["src/MyApp/Database/Migrations/**"],
"projects": [
{
"project": "MyProject.Tests",
"command": "dotnet",
"args": "run --project tests/MyProject.Tests --no-build --",
"filterTemplate": "--filter-class {classes}",
"classJoin": " ",
"group": "unit"
}
]
},
"analyzers": {
"paths": ["analyzers/"]
},
"fileCommands": [
{
"pattern": "*.fsx",
"command": "dotnet",
"args": "fsi --typecheck-only"
}
],
"coverage": {
"configPath": "coverage-ratchet.json",
"searchDir": "coverage"
}
}
Reference
| Field | Type | Default | Description |
|---|---|---|---|
build |
object \| bool |
{"command": "dotnet", "args": "build"} |
Build command. false disables. |
format |
bool |
true |
Enable Fantomas format-on-save preprocessor. |
lint |
bool |
true |
Enable FSharpLint plugin. Uses fsharplint.json if found. |
cache |
string \| bool |
"file" |
Cache strategy: "none", "memory", or "file". ("jj" is a legacy alias for "file".) |
tests |
object |
— | Test runner config. See below. |
coverage |
object |
— | Coverage threshold checking. |
analyzers |
object |
— | F# Analyzers SDK integration. |
fileCommands |
array |
[] |
Custom commands triggered by file patterns. |
exclude |
string[] |
[] |
Gitignore-style globs (repo-root-relative) for paths to skip entirely — watching, building, checking. (obj/ + bin/ are always skipped, independent of this.) |
includeOutsideRepo |
bool |
false |
Report on compile items that resolve outside the repo root — e.g. NuGet-injected _content source (xunit's DefaultRunnerReporters.fs), or files above/beside the repo. Default false: the report-producing plugins (analyzers, lint) skip such third-party source — it's compiled into your project, but not yours to lint, and a latent analyzer-crash surface (AUTOMATION-49). Set true to lint them anyway. |
For memory/idle-exit, FSEvents latency, and per-task timeout keys, see Memory & tuning.
build fields:
| Field | Type | Default | Description |
|---|---|---|---|
command |
string |
"dotnet" |
Build command. |
args |
string |
"build" |
Arguments to the build command. |
buildTemplate |
string |
— | Template for incremental builds. {projects} is replaced with changed project paths. |
tests fields:
| Field | Type | Default | Description |
|---|---|---|---|
beforeRun |
string |
— | Command to run before each test run (e.g. "dotnet build"). |
dependsOn |
string[] |
[] |
Repo-root-relative globs (*, ?, **) naming external test inputs the symbol-diff can't see — DB migrations, generated files, schemas. Their content hash salts the test cache key, so editing a matched file forces a real test re-run even when no test source changed. |
coverageDir |
string |
"coverage" |
Directory (repo-root-relative) for per-project Cobertura artifacts. |
projects |
array |
[] |
List of test project configurations. |
tests.projects[] fields:
| Field | Type | Default | Description |
|---|---|---|---|
project |
string |
"unknown" |
Project name (used for filtering and display). |
command |
string |
"dotnet" |
Test runner command. |
args |
string |
"test --project <project>" |
Arguments to the test runner. |
group |
string |
"default" |
Group name (for running subsets). |
environment |
object |
{} |
Extra environment variables as "KEY": "VALUE" pairs. |
filterTemplate |
string |
— | Template for class-based filtering. {classes} is replaced with affected test class names. |
classJoin |
string |
" " |
Separator for joining class names in the filter. |
reportVerificationFormat |
string |
"auto" |
How the pass/fail verdict's structured test report is obtained. The report (not the process exit code) decides green/red. auto injects --report-ctrf only for a runner detected as xUnit.v3 (else falls back to the dotnet heuristic); ctrf always injects it; off never injects it and the exit code stays authoritative. |
analyzers fields:
| Field | Type | Default | Description |
|---|---|---|---|
paths |
string[] |
— | Directories containing analyzer DLLs. Relative paths resolved from repo root. |
fileCommands[] fields:
| Field | Type | Default | Description |
|---|---|---|---|
pattern |
string |
"*.fsx" |
File extension pattern to match (e.g. "*.fsx", "*.sql"). |
command |
string |
"echo" |
Command to run when a matching file changes. |
args |
string |
"" |
Arguments to the command. |
coverage fields:
| Field | Type | Default | Description |
|---|---|---|---|
configPath |
string |
"coverage-ratchet.json" |
Path to the coverage-ratchet thresholds file (relative to repo root or absolute). |
searchDir |
string |
"." |
Directory tree to search for coverage.cobertura.xml files after each test run. |
Cache directory
FsHotWatch keeps its check-result cache and the TestPrune database in .fshw/
at the repository root. Add it to your .gitignore:
.fshw/
What it does and doesn't cache. The on-disk cache stores FCS check
results, keyed by file content — so a fresh daemon can replay unchanged files
instead of re-checking them. It does not persist the compiler's in-memory
warmth: FSharpChecker and its FCS caches are rebuilt from cold on every daemon
start, so the first scan after a (re)start still pays that warm-up before the
cached results start landing.
Writing plugins
Plugins are declarative update functions over a shared warm compiler: you define how your state reacts to events (file checked, build completed, tests finished), and the framework manages the agent, status, caching, and IPC. See Writing a plugin.
Memory & tuning
The daemon keeps the F# compiler warm, which costs memory. FsHotWatch ships with conservative defaults (aggressive GC, optional idle-exit) so this stays in check — see Memory & tuning if you want to adjust them.
| Product | Versions 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. |
-
net10.0
- FSharp.Compiler.Service (>= 43.12.204)
- FSharp.Core (>= 10.1.302)
- FSharp.Data.Adaptive (>= 1.2.26)
- Ignore (>= 0.2.1)
- Ionide.ProjInfo (>= 0.74.2)
- Ionide.ProjInfo.FCS (>= 0.74.2)
- MessagePack (>= 2.5.301)
- Nerdbank.MessagePack (>= 1.2.4)
- StreamJsonRpc (>= 2.24.92)
- System.Security.Cryptography.Xml (>= 10.0.9)
NuGet packages (7)
Showing the top 5 NuGet packages that depend on FsHotWatch:
| Package | Downloads |
|---|---|
|
FsHotWatch.TestPrune
FsHotWatch plugin for TestPrune test impact analysis |
|
|
FsHotWatch.Analyzers
FsHotWatch plugin for F# Analyzers SDK integration |
|
|
FsHotWatch.Build
FsHotWatch plugin that runs dotnet build and emits BuildCompleted events |
|
|
FsHotWatch.Lint
FsHotWatch plugin for FSharpLint integration |
|
|
FsHotWatch.Fantomas
FsHotWatch plugin for Fantomas format checking |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.10.0-alpha.3 | 45 | 7/15/2026 |
| 0.10.0-alpha.2 | 60 | 7/8/2026 |
| 0.10.0-alpha.1 | 69 | 7/5/2026 |
| 0.9.0-alpha.1 | 73 | 7/3/2026 |
| 0.8.0-alpha.34 | 61 | 7/2/2026 |
| 0.8.0-alpha.33 | 93 | 6/24/2026 |
| 0.8.0-alpha.32 | 75 | 6/18/2026 |
| 0.8.0-alpha.31 | 106 | 6/17/2026 |
| 0.8.0-alpha.30 | 131 | 6/16/2026 |
| 0.8.0-alpha.29 | 72 | 6/15/2026 |
| 0.8.0-alpha.28 | 71 | 6/12/2026 |
| 0.8.0-alpha.27 | 75 | 6/11/2026 |
| 0.8.0-alpha.26 | 67 | 6/10/2026 |
| 0.8.0-alpha.25 | 65 | 6/10/2026 |
| 0.8.0-alpha.24 | 93 | 6/8/2026 |
| 0.8.0-alpha.23 | 65 | 6/7/2026 |
| 0.8.0-alpha.22 | 67 | 6/7/2026 |
| 0.8.0-alpha.21 | 63 | 6/6/2026 |
| 0.8.0-alpha.20 | 64 | 6/5/2026 |
| 0.8.0-alpha.19 | 65 | 6/4/2026 |