diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f1d0ff14..6d4cfaa2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -100,6 +100,13 @@ jobs:
PACKAGE_VERSION: ${{ steps.gitversion.outputs.semVer }}
run: ./scripts/Verify-Packages.ps1 -PackagesPath artifacts/package/release -Version $env:PACKAGE_VERSION
+ - name: Compile and execute documentation snippets
+ if: matrix.os == 'ubuntu-latest'
+ shell: pwsh
+ env:
+ PACKAGE_VERSION: ${{ steps.gitversion.outputs.semVer }}
+ run: ./scripts/Verify-DocSnippets.ps1 -PackagesPath artifacts/package/release -Version $env:PACKAGE_VERSION
+
- name: Upload packages
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9c91a7fe..b6d06a48 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -25,6 +25,7 @@
+
diff --git a/README.md b/README.md
index 4a88bbd8..7d122cd8 100644
--- a/README.md
+++ b/README.md
@@ -130,7 +130,7 @@ Shield.CircuitBreaker(o =>
o.OnStateChanged = c => logger.LogWarning("Circuit {From} -> {To}", c.From, c.To);
});
-monitor.State; // Closed / Open / HalfOpen / Isolated
+_ = monitor.State; // Closed / Open / HalfOpen / Isolated
monitor.Isolate(); // force open (maintenance switch)
monitor.Reset(); // close and clear metrics
```
@@ -173,11 +173,13 @@ var shield = Shield.For()
.Fallback(Config.Default);
// Or compute it, with access to the typed failure:
-.Fallback((outcome, ct) =>
-{
- logger.LogError(outcome.Exception, "Using cached config");
- return new ValueTask(cache.Get());
-});
+var computed = Shield.For()
+ .When()
+ .Fallback((outcome, ct) =>
+ {
+ logger.LogError(outcome.Exception, "Using cached config");
+ return new ValueTask(cache.Get());
+ });
// Void executions have their own fallback on the plain Shield:
Shield.When()
@@ -237,6 +239,7 @@ The same shield serves any result type, sync or async. (One exception: hedging i
## Dependency injection
+
```csharp
services.AddShield("github", Shield.Timeout(TimeSpan.FromSeconds(10)).Retry(3));
services.AddShield("downstream",
@@ -248,7 +251,10 @@ services.AddShield("github", builder.Configuration.GetSection("Resilience:GitHub
// Consume via the registry…
var shield = registry.GetShield("github"); // IKevlarRegistry
// …or as a keyed service
-public sealed class GitHubClient([FromKeyedServices("github")] Shield shield) { }
+public sealed class GitHubClient([FromKeyedServices("github")] Shield shield)
+{
+ public Shield Resilience { get; } = shield;
+}
```
## HTTP
@@ -275,6 +281,7 @@ And `dotnet add package Kevlar.Analyzers` adds compile-time checks — starting
Everything in Kevlar is a `Strategy` — middleware over an `Outcome` pipeline. Write your own:
+
```csharp
public sealed class LoggingStrategy(ILogger logger) : Strategy
{
diff --git a/docs/docs/composition.md b/docs/docs/composition.md
index b9fc431d..0935817a 100644
--- a/docs/docs/composition.md
+++ b/docs/docs/composition.md
@@ -77,6 +77,7 @@ var background = Shield.Timeout(TimeSpan.FromSeconds(30)).Retry(5).Wrap(downstr
A [handling clause](handling-failures.md) applies to the strategy it precedes *and* to every reactive strategy chained after it, until you write a new clause:
+
```csharp
Shield
.When()
@@ -90,6 +91,7 @@ Shield
One ordering is always a bug: a `Fallback` chained *after* (inside) a retry, hedge or circuit breaker that shares its handling clause. The fallback recovers every failure before the outer strategy sees one, silently disabling it. Kevlar refuses to build that chain:
+
```csharp
Shield.For().Retry(3).Fallback(-1);
// InvalidOperationException: … makes Retry(3, …) unreachable.
diff --git a/docs/docs/custom-strategies.md b/docs/docs/custom-strategies.md
index 8a1b0e84..550feb6e 100644
--- a/docs/docs/custom-strategies.md
+++ b/docs/docs/custom-strategies.md
@@ -8,6 +8,7 @@ Everything in Kevlar is a `Strategy` — middleware over an `Outcome` pipelin
## A logging strategy
+
```csharp
public sealed class LoggingStrategy(ILogger logger) : Strategy
{
@@ -29,6 +30,7 @@ var shield = Shield.Use(new LoggingStrategy(logger)).Retry(3);
Override `Describe()` so `shield.ToString()` names your strategy meaningfully in [pipeline descriptions](observability.md#pipeline-descriptions):
+
```csharp
public override string Describe() => "Logging";
```
@@ -60,6 +62,7 @@ The power is in how many times you call `next`:
Strategies return failures as `Outcome` values rather than throwing, so outer strategies can react to them cheaply:
+
```csharp
public override async ValueTask> ExecuteAsync(
Continuation next, KevlarContext context)
@@ -96,6 +99,7 @@ The context flows through the whole pipeline:
- `context.IsSynchronous` — `true` under `Execute`; branch on it if your strategy would otherwise block or break a sync caller (hedging throws for sync callers this way).
- `context.Properties` — a typed property bag: `Set(key, value)`, `TryGet(key, out value)`, `GetOrDefault(key)`, keyed by `KevlarKey`:
+
```csharp
static readonly KevlarKey TenantId = new("tenant-id");
diff --git a/docs/docs/dependency-injection.md b/docs/docs/dependency-injection.md
index 05dfdb91..42518f33 100644
--- a/docs/docs/dependency-injection.md
+++ b/docs/docs/dependency-injection.md
@@ -26,6 +26,7 @@ Because shields are immutable and thread-safe, each named shield is a singleton
## Consuming via the registry
+
```csharp
public sealed class GitHubClient(IKevlarRegistry registry)
{
@@ -79,10 +80,11 @@ The schema is `ShieldDefinition`: optional `Timeout`, `Retry`, `CircuitBreaker`,
Named shields are also registered as keyed services, so you can skip the registry entirely:
+
```csharp
public sealed class GitHubClient([FromKeyedServices("github")] Shield shield)
{
- // ...
+ public Shield Resilience { get; } = shield;
}
```
diff --git a/docs/docs/executing.md b/docs/docs/executing.md
index 9cf52a63..14958ce0 100644
--- a/docs/docs/executing.md
+++ b/docs/docs/executing.md
@@ -16,6 +16,7 @@ The only exception: [hedging](strategies/hedging.md) is inherently concurrent an
`Task` and `ValueTask` delegates both work — your existing `Task`-returning methods flow straight in, no wrapping:
+
```csharp
Task LoadUserAsync(int id, CancellationToken ct) => ...; // ordinary Task method
diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md
index f8c9e798..ac4799e6 100644
--- a/docs/docs/getting-started.md
+++ b/docs/docs/getting-started.md
@@ -42,6 +42,7 @@ Three things to notice:
Shields are immutable and thread-safe. Build one, store it in a `static readonly` field or register it in [DI](dependency-injection.md), and use it for every call to that dependency:
+
```csharp
private static readonly Shield GitHubShield = Shield
.Timeout(TimeSpan.FromSeconds(10))
diff --git a/docs/docs/handling-failures.md b/docs/docs/handling-failures.md
index 81929b3a..28f12099 100644
--- a/docs/docs/handling-failures.md
+++ b/docs/docs/handling-failures.md
@@ -49,6 +49,7 @@ Shield.For().WhenDefault().Retry(2); // retry when the result is null /
A clause applies to the strategy it creates *and* to every reactive strategy chained after it, until you write a new clause:
+
```csharp
Shield
.When() // clause #1
diff --git a/docs/docs/library-authors.md b/docs/docs/library-authors.md
index 707a7857..206ad665 100644
--- a/docs/docs/library-authors.md
+++ b/docs/docs/library-authors.md
@@ -6,6 +6,7 @@ sidebar_position: 10
You ship a library that talks to something flaky — an HTTP API, a database, a queue — and you want *your users* to decide how resilient those calls are. The integration surface is one parameter:
+
```csharp
public sealed class ReportsClient
{
@@ -50,6 +51,7 @@ And for testing, you don't need a mock: `Shield.Empty` is the no-op, and fault i
If callers should be able to react to *result values* — retry on `null`, hedge on an error status — accept a `Shield` instead:
+
```csharp
public ProfileClient(Shield? shield = null)
=> _shield = shield ?? Shield.Empty;
@@ -64,6 +66,7 @@ var client = new ProfileClient(
`Shield.Empty` is the right default when resilience is genuinely optional. If your library *should* retry out of the box, default to a real shield instead — and think about where its state lives:
+
```csharp
public ReportsClient(HttpClient http, Shield? shield = null)
=> _shield = shield ?? Shield.Retry(3).Timeout(TimeSpan.FromSeconds(10));
diff --git a/docs/docs/observability.md b/docs/docs/observability.md
index 3c7ab41f..9a66bef5 100644
--- a/docs/docs/observability.md
+++ b/docs/docs/observability.md
@@ -10,6 +10,7 @@ Shields are observable without any setup: they describe themselves as strings, p
`shield.ToString()` prints the whole pipeline, outermost strategy first, with each strategy's configuration:
+
```csharp
var shield = Shield
.Timeout(TimeSpan.FromSeconds(30))
diff --git a/docs/docs/strategies/circuit-breaker.md b/docs/docs/strategies/circuit-breaker.md
index 366aea13..d5535774 100644
--- a/docs/docs/strategies/circuit-breaker.md
+++ b/docs/docs/strategies/circuit-breaker.md
@@ -65,7 +65,7 @@ var shield = Shield.CircuitBreaker(o =>
o.OnStateChanged = c => logger.LogWarning("Circuit {From} -> {To}", c.From, c.To);
});
-monitor.State; // Closed / Open / HalfOpen / Isolated
+_ = monitor.State; // Closed / Open / HalfOpen / Isolated
monitor.StateChanged += e => metrics.Record(e.To);
monitor.Isolate(); // force open (maintenance switch)
monitor.Reset(); // close and clear metrics
diff --git a/docs/docs/strategies/fallback.md b/docs/docs/strategies/fallback.md
index 8e1e7f7a..eb96ee8e 100644
--- a/docs/docs/strategies/fallback.md
+++ b/docs/docs/strategies/fallback.md
@@ -28,6 +28,7 @@ A void fallback guards void executions only; executing a result-returning delega
## Three shapes
+
```csharp
// 1. A constant value:
.Fallback(Config.Default)
@@ -45,6 +46,7 @@ A void fallback guards void executions only; executing a result-returning delega
Every overload takes an optional `onFallback` callback:
+
```csharp
.Fallback(Config.Default,
onFallback: e => metrics.Increment("config.fallback"))
diff --git a/docs/docs/strategies/retry.md b/docs/docs/strategies/retry.md
index 78436747..12f7e659 100644
--- a/docs/docs/strategies/retry.md
+++ b/docs/docs/strategies/retry.md
@@ -30,8 +30,8 @@ Backoff.Constant(TimeSpan.FromSeconds(1)); // 1s, 1s, 1s, ...
Backoff.Linear(TimeSpan.FromMilliseconds(500)); // 500ms, 1s, 1.5s, ...
Backoff.Exponential(TimeSpan.FromSeconds(1)); // ~1s, ~2s, ~4s, ... (jittered)
Backoff.Custom(attempt => TimeSpan.FromMilliseconds(100 * attempt)); // attempt is 1-based
-Backoff.None; // no delay between attempts
-Backoff.Default; // what bare Retry(n) uses
+_ = Backoff.None; // no delay between attempts
+_ = Backoff.Default; // what bare Retry(n) uses
```
- `Exponential(initialDelay, factor = 2.0, maxDelay = null, jitter = true)` — jitter scales each delay by a random factor in [0.5, 1.5) to avoid synchronized retry storms. `Backoff.Default` = `Exponential(250ms, maxDelay: 30s)`.
diff --git a/docs/docs/testing.md b/docs/docs/testing.md
index 5a50fbc5..adb2bce3 100644
--- a/docs/docs/testing.md
+++ b/docs/docs/testing.md
@@ -31,6 +31,24 @@ dotnet stryker --config-file stryker-config.json
Pop-Location
```
+## Documentation snippet gates
+
+Every C# fence in the README and Docusaurus documentation is compiled on pull requests. The harness extracts source directly from Markdown, generates isolated call sites, and restores Kevlar from the locally packed `.nupkg` files. This keeps documentation as the single source of truth and catches stale package IDs, API names, and overloads.
+
+Complete snippets need no marker. A fragment that intentionally depends on omitted application code must place an explicit reason immediately before its fence:
+
+```markdown
+
+```
+
+Class-level declarations use `doc-test-declaration`; mixed blocks can split declarations from call sites with `doc-test-tail-declaration`; isolated custom strategy members use `doc-test-strategy-member`; safe behavioral samples use `doc-test-run`. Unknown or malformed directives fail validation. Shell `dotnet add package` IDs and `dotnet run --project` paths are validated separately.
+
+After packing with a chosen version, run the same package-consumer check locally:
+
+```powershell
+./scripts/Verify-DocSnippets.ps1 -PackagesPath artifacts/package/release -Version $packageVersion
+```
+
Every delay, timeout and time window in Kevlar runs on a `TimeProvider`. Swap in a fake and your tests never actually wait:
```csharp
diff --git a/scripts/Verify-DocSnippets.ps1 b/scripts/Verify-DocSnippets.ps1
new file mode 100644
index 00000000..b06926b2
--- /dev/null
+++ b/scripts/Verify-DocSnippets.ps1
@@ -0,0 +1,365 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [string]$PackagesPath,
+
+ [Parameter(Mandatory)]
+ [string]$Version
+)
+
+$ErrorActionPreference = 'Stop'
+
+$repositoryRoot = Split-Path $PSScriptRoot -Parent
+$resolvedPackagesPath = (Resolve-Path -LiteralPath $PackagesPath).Path
+$generatedDirectory = Join-Path $repositoryRoot 'artifacts/doc-tests/generated'
+$generatedPath = Join-Path $generatedDirectory 'GeneratedSnippets.g.cs'
+$nugetConfigPath = Join-Path $generatedDirectory 'NuGet.config'
+$projectPath = Join-Path $repositoryRoot 'tests/Kevlar.DocTests/Kevlar.DocTests.csproj'
+$documentPaths = @(
+ (Join-Path $repositoryRoot 'README.md')
+ Get-ChildItem (Join-Path $repositoryRoot 'docs/docs') -Recurse -File -Include '*.md', '*.mdx' |
+ Sort-Object FullName |
+ ForEach-Object FullName
+)
+
+$requiredPackages = @(
+ 'Kevlar'
+ 'Kevlar.Analyzers'
+ 'Kevlar.Extensions.DependencyInjection'
+ 'Kevlar.Extensions.Http'
+)
+
+foreach ($packageId in $requiredPackages)
+{
+ $packagePath = Join-Path $resolvedPackagesPath "$packageId.$Version.nupkg"
+ if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf))
+ {
+ throw "Documented package '$packageId' was not packed at '$packagePath'."
+ }
+}
+
+$snippets = [System.Collections.Generic.List[object]]::new()
+$installPackageIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+$projectCommands = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+
+foreach ($documentPath in $documentPaths)
+{
+ $relativePath = [IO.Path]::GetRelativePath($repositoryRoot, $documentPath).Replace('\', '/')
+ $lines = Get-Content -LiteralPath $documentPath
+ $csharpOrdinal = 0
+
+ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++)
+ {
+ if ($lines[$lineIndex] -match '^```csharp\s*$')
+ {
+ $csharpOrdinal++
+ $startLine = $lineIndex + 2
+ $body = [System.Collections.Generic.List[string]]::new()
+ for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch '^```\s*$'; $lineIndex++)
+ {
+ $body.Add($lines[$lineIndex])
+ }
+
+ if ($lineIndex -ge $lines.Count)
+ {
+ throw "Unclosed C# fence at ${relativePath}:$startLine."
+ }
+
+ $directive = if ($startLine -ge 3) { $lines[$startLine - 3].Trim() } else { '' }
+ $ignoreReason = $null
+ if ($directive -match '^$')
+ {
+ $ignoreReason = $Matches[1]
+ }
+
+ $ignored = $null -ne $ignoreReason
+ if ($directive -match '^$')
+ {
+ $runName = $Matches[1]
+ }
+
+ if ($directive -match '^$')
+ {
+ $mode = 'declaration'
+ $splitBefore = $Matches[1]
+ }
+ elseif ($directive -match '^$')
+ {
+ $mode = 'tail-declaration'
+ $splitBefore = $Matches[1]
+ }
+ elseif ($directive -match '^$')
+ {
+ $mode = 'declaration'
+ }
+ elseif ($directive -match '^$')
+ {
+ $mode = 'strategy-member'
+ }
+ elseif ($directive -match '^