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 '^