diff --git a/.github/workflows/justdummies-mutation.yml b/.github/workflows/justdummies-mutation.yml index e36f7dcb..db909515 100644 --- a/.github/workflows/justdummies-mutation.yml +++ b/.github/workflows/justdummies-mutation.yml @@ -54,13 +54,16 @@ jobs: # generator is red. fail-fast: false matrix: - # The two JustDummies packages this repository SHIPS: the generator and its xUnit v3 adapter - # (ADR-0039). The FirstClassErrors libraries live in `mutation.yml`. See ADR-0043. + # What the JustDummies packages SHIP: the generator, its xUnit v3 adapter (ADR-0039), and the + # analyzers packed inside the generator package (ADR-0044). The FirstClassErrors libraries live + # in `mutation.yml`. See ADR-0043. include: - name: justdummies config: build/stryker/justdummies.json - name: justdummies-xunit config: build/stryker/justdummies-xunit.json + - name: justdummies-analyzers + config: build/stryker/justdummies-analyzers.json steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -196,6 +199,8 @@ jobs: config: build/stryker/justdummies.json - name: justdummies-xunit config: build/stryker/justdummies-xunit.json + - name: justdummies-analyzers + config: build/stryker/justdummies-analyzers.json steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/FirstClassErrors.sln b/FirstClassErrors.sln index e827b2b2..2d2ad893 100644 --- a/FirstClassErrors.sln +++ b/FirstClassErrors.sln @@ -65,6 +65,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JustDummies.Xunit.UnitTests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JustDummies.PropertyTests", "JustDummies.PropertyTests\JustDummies.PropertyTests.csproj", "{A09FC4C6-1470-4768-A1C0-2D4E42788FCF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JustDummies.Analyzers", "JustDummies.Analyzers\JustDummies.Analyzers.csproj", "{CD456AD9-0736-4333-89D8-4EF160185CDD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JustDummies.Analyzers.UnitTests", "JustDummies.Analyzers.UnitTests\JustDummies.Analyzers.UnitTests.csproj", "{09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -373,6 +377,30 @@ Global {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|x64.Build.0 = Release|Any CPU {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|x86.ActiveCfg = Release|Any CPU {A09FC4C6-1470-4768-A1C0-2D4E42788FCF}.Release|x86.Build.0 = Release|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Debug|x64.ActiveCfg = Debug|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Debug|x64.Build.0 = Debug|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Debug|x86.ActiveCfg = Debug|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Debug|x86.Build.0 = Debug|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Release|Any CPU.Build.0 = Release|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Release|x64.ActiveCfg = Release|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Release|x64.Build.0 = Release|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Release|x86.ActiveCfg = Release|Any CPU + {CD456AD9-0736-4333-89D8-4EF160185CDD}.Release|x86.Build.0 = Release|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Debug|x64.ActiveCfg = Debug|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Debug|x64.Build.0 = Debug|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Debug|x86.ActiveCfg = Debug|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Debug|x86.Build.0 = Debug|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Release|Any CPU.Build.0 = Release|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Release|x64.ActiveCfg = Release|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Release|x64.Build.0 = Release|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Release|x86.ActiveCfg = Release|Any CPU + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -403,6 +431,8 @@ Global {BF67DBAE-EBE1-45CE-86E5-B2E9E3D4EF6B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {75FD5E2C-A871-4659-8A01-13461FED84EB} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB} {A09FC4C6-1470-4768-A1C0-2D4E42788FCF} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB} + {CD456AD9-0736-4333-89D8-4EF160185CDD} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {09EED96D-C8A7-44B2-BAEB-2E91A1ABFC8D} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4988972E-3E0D-4F48-8656-0E67ECE994BF} diff --git a/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs b/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs new file mode 100644 index 00000000..7a90b64f --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -0,0 +1,55 @@ +using System.Collections.Immutable; + +using JustDummies; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace JustDummies.Analyzers.UnitTests; + +/// +/// Minimal in-process harness: compiles a C# snippet against the running runtime plus the JustDummies core, runs a +/// single analyzer over it, and returns the analyzer diagnostics. Deliberately dependency-free (no +/// Microsoft.CodeAnalysis.Testing) so it composes cleanly with xUnit v3 and NFluent — the same shape as +/// FirstClassErrors' harness. +/// +internal static class AnalyzerTestHarness { + + private static readonly ImmutableArray References = BuildReferences(); + + public static async Task> GetDiagnosticsAsync(DiagnosticAnalyzer analyzer, string source) { + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "JustDummies.Analyzers.TestSnippet", + syntaxTrees: new[] { syntaxTree }, + references: References, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + return await withAnalyzers.GetAnalyzerDiagnosticsAsync(); + } + + private static ImmutableArray BuildReferences() { + List references = new(); + + // Reference the running runtime's assemblies so snippets resolve System types without pinning a ref pack. + string trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; + foreach (string path in trustedAssemblies.Split(Path.PathSeparator)) { + if (string.IsNullOrEmpty(path)) { continue; } + try { + references.Add(MetadataReference.CreateFromFile(path)); + } catch { + // Skip any native or otherwise unloadable entry in the TPA list. + } + } + + // The JustDummies core, so Any.Reproducibly / Any.ReproduciblyAsync resolve inside the snippet. + references.Add(MetadataReference.CreateFromFile(typeof(Any).Assembly.Location)); + + return references.ToImmutableArray(); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/Jd001AsyncBodyPassedToReproduciblyTests.cs b/JustDummies.Analyzers.UnitTests/Jd001AsyncBodyPassedToReproduciblyTests.cs new file mode 100644 index 00000000..f06cb435 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd001AsyncBodyPassedToReproduciblyTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd001AsyncBodyPassedToReproduciblyTests { + + [Fact] + public async Task Reports_an_async_lambda_passed_to_Reproducibly() { + const string source = """ + using JustDummies; + using System.Threading.Tasks; + + public static class Sample { + public static void M() { + Any.Reproducibly(async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new AsyncBodyPassedToReproduciblyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD001"); + Check.That(diagnostics[0].GetMessage()).Contains("ReproduciblyAsync"); + } + + [Fact] + public async Task Reports_an_async_lambda_passed_to_the_seeded_Reproducibly() { + const string source = """ + using JustDummies; + using System.Threading.Tasks; + + public static class Sample { + public static void M() { + Any.Reproducibly(42, async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new AsyncBodyPassedToReproduciblyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD001"); + } + + [Fact] + public async Task Does_not_report_a_synchronous_lambda() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + Any.Reproducibly(() => { var _ = Any.Int32().Generate(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new AsyncBodyPassedToReproduciblyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_Reproducibly_that_is_not_Any() { + // A same-named method on another type must not trip the rule — the analyzer keys on JustDummies.Any. + const string source = """ + using System; + using System.Threading.Tasks; + + public static class Other { + public static void Reproducibly(Action body) { } + } + + public static class Sample { + public static void M() { + Other.Reproducibly(async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new AsyncBodyPassedToReproduciblyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_an_async_lambda_passed_to_ReproduciblyAsync() { + const string source = """ + using JustDummies; + using System.Threading.Tasks; + + public static class Sample { + public static async Task M() { + await Any.ReproduciblyAsync(async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new AsyncBodyPassedToReproduciblyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/Jd002DiscardedReproduciblyAsyncResultTests.cs b/JustDummies.Analyzers.UnitTests/Jd002DiscardedReproduciblyAsyncResultTests.cs new file mode 100644 index 00000000..3ea44d0a --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd002DiscardedReproduciblyAsyncResultTests.cs @@ -0,0 +1,111 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd002DiscardedReproduciblyAsyncResultTests { + + [Fact] + public async Task Reports_when_the_task_is_discarded_as_a_statement() { + const string source = """ + using JustDummies; + using System.Threading.Tasks; + + public static class Sample { + public static void M() { + Any.ReproduciblyAsync(async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DiscardedReproduciblyAsyncResultAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD002"); + Check.That(diagnostics[0].GetMessage()).Contains("ReproduciblyAsync"); + } + + [Fact] + public async Task Reports_when_the_task_is_assigned_to_a_discard() { + const string source = """ + using JustDummies; + using System.Threading.Tasks; + + public static class Sample { + public static void M() { + _ = Any.ReproduciblyAsync(async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DiscardedReproduciblyAsyncResultAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD002"); + } + + [Fact] + public async Task Does_not_report_a_ReproduciblyAsync_that_is_not_Any() { + // A same-named method on another type must not trip the rule — the analyzer keys on JustDummies.Any. + const string source = """ + using System; + using System.Threading.Tasks; + + public static class Other { + public static Task ReproduciblyAsync(Func body) => Task.CompletedTask; + } + + public static class Sample { + public static void M() { + Other.ReproduciblyAsync(async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DiscardedReproduciblyAsyncResultAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_the_task_is_awaited() { + const string source = """ + using JustDummies; + using System.Threading.Tasks; + + public static class Sample { + public static async Task M() { + await Any.ReproduciblyAsync(async () => { await Task.Yield(); }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DiscardedReproduciblyAsyncResultAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_the_task_is_captured() { + const string source = """ + using JustDummies; + using System.Threading.Tasks; + + public static class Sample { + public static Task M() { + Task task = Any.ReproduciblyAsync(async () => { await Task.Yield(); }); + + return task; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DiscardedReproduciblyAsyncResultAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/JustDummies.Analyzers.UnitTests.csproj b/JustDummies.Analyzers.UnitTests/JustDummies.Analyzers.UnitTests.csproj new file mode 100644 index 00000000..292ec2b2 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/JustDummies.Analyzers.UnitTests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/JustDummies.Analyzers/AnalyzerReleases.Shipped.md b/JustDummies.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..f50bb1fe --- /dev/null +++ b/JustDummies.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..5b4a27a3 --- /dev/null +++ b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|-----------------------------|----------|------------------------------------------- +JD001 | JustDummies.Reproducibility | Error | AsyncBodyPassedToReproduciblyAnalyzer +JD002 | JustDummies.Reproducibility | Error | DiscardedReproduciblyAsyncResultAnalyzer diff --git a/JustDummies.Analyzers/AsyncBodyPassedToReproduciblyAnalyzer.cs b/JustDummies.Analyzers/AsyncBodyPassedToReproduciblyAnalyzer.cs new file mode 100644 index 00000000..e5d1aba1 --- /dev/null +++ b/JustDummies.Analyzers/AsyncBodyPassedToReproduciblyAnalyzer.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD001 — reports an async lambda passed to the synchronous Any.Reproducibly(Action). Bound to an +/// it becomes async void, so the body's failures after the first await +/// escape the reproducible scope entirely and never fail the test. Use Any.ReproduciblyAsync(Func<Task>) +/// and await it. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AsyncBodyPassedToReproduciblyAnalyzer : DiagnosticAnalyzer { + + private const string AnyMetadataName = "JustDummies.Any"; + private const string ReproduciblyMethodName = "Reproducibly"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.AsyncBodyPassedToReproducibly); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? anyType = context.Compilation.GetTypeByMetadataName(AnyMetadataName); + if (anyType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, anyType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol anyType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != ReproduciblyMethodName || !SymbolEqualityComparer.Default.Equals(method.ContainingType, anyType)) { return; } + + foreach (IArgumentOperation argument in invocation.Arguments) { + if (TryGetAsyncLambda(argument.Value, out IAnonymousFunctionOperation? lambda)) { + context.ReportDiagnostic(Diagnostic.Create(Descriptors.AsyncBodyPassedToReproducibly, lambda!.Syntax.GetLocation())); + } + } + } + + // An async lambda bound to the Action parameter is wrapped in a delegate creation; unwrap it and read the + // anonymous function's own IsAsync (its return is void — that is precisely the async-void hazard). + private static bool TryGetAsyncLambda(IOperation value, out IAnonymousFunctionOperation? lambda) { + IOperation inner = value is IDelegateCreationOperation delegateCreation ? delegateCreation.Target : value; + if (inner is IAnonymousFunctionOperation { Symbol.IsAsync: true } anonymous) { + lambda = anonymous; + + return true; + } + + lambda = null; + + return false; + } + +} diff --git a/JustDummies.Analyzers/Descriptors.cs b/JustDummies.Analyzers/Descriptors.cs new file mode 100644 index 00000000..6177f2f4 --- /dev/null +++ b/JustDummies.Analyzers/Descriptors.cs @@ -0,0 +1,30 @@ +using Microsoft.CodeAnalysis; + +namespace JustDummies.Analyzers; + +/// +/// The for every JustDummies rule. One field per JDxxx. +/// +internal static class Descriptors { + + public static readonly DiagnosticDescriptor AsyncBodyPassedToReproducibly = new( + id: DiagnosticIds.AsyncBodyPassedToReproducibly, + title: "An asynchronous body is passed to Any.Reproducibly", + messageFormat: "Pass the asynchronous body to Any.ReproduciblyAsync and await it: Any.Reproducibly takes an Action, so an async lambda runs as 'async void' and its failures never reach the test", + category: DiagnosticCategories.Reproducibility, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Any.Reproducibly takes a synchronous Action. An async lambda bound to it becomes 'async void', whose exceptions escape the reproducible scope entirely and never fail the test. Use Any.ReproduciblyAsync(Func) and await it.", + helpLinkUri: HelpLinks.For(DiagnosticIds.AsyncBodyPassedToReproducibly)); + + public static readonly DiagnosticDescriptor DiscardedReproduciblyAsyncResult = new( + id: DiagnosticIds.DiscardedReproduciblyAsyncResult, + title: "The task returned by Any.ReproduciblyAsync is discarded", + messageFormat: "Await the task returned by Any.ReproduciblyAsync; discarding it silently drops the body's failures", + category: DiagnosticCategories.Reproducibility, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Any.ReproduciblyAsync returns a Task that faults with the body's exception. Discarding it (as a bare statement or via '_ =') lets a failing test pass green. Await it.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DiscardedReproduciblyAsyncResult)); + +} diff --git a/JustDummies.Analyzers/DiagnosticCategories.cs b/JustDummies.Analyzers/DiagnosticCategories.cs new file mode 100644 index 00000000..c587ae28 --- /dev/null +++ b/JustDummies.Analyzers/DiagnosticCategories.cs @@ -0,0 +1,10 @@ +namespace JustDummies.Analyzers; + +/// +/// Categories used to group JustDummies diagnostics in the IDE and in .editorconfig. +/// +internal static class DiagnosticCategories { + + public const string Reproducibility = "JustDummies.Reproducibility"; + +} diff --git a/JustDummies.Analyzers/DiagnosticIds.cs b/JustDummies.Analyzers/DiagnosticIds.cs new file mode 100644 index 00000000..fed5fb0d --- /dev/null +++ b/JustDummies.Analyzers/DiagnosticIds.cs @@ -0,0 +1,13 @@ +namespace JustDummies.Analyzers; + +/// +/// Stable identifiers for every JustDummies diagnostic. JD is the JustDummies prefix, mirroring +/// FCE for FirstClassErrors; the number is only a stable handle. +/// +internal static class DiagnosticIds { + + // Category: Reproducibility + public const string AsyncBodyPassedToReproducibly = "JD001"; + public const string DiscardedReproduciblyAsyncResult = "JD002"; + +} diff --git a/JustDummies.Analyzers/DiscardedReproduciblyAsyncResultAnalyzer.cs b/JustDummies.Analyzers/DiscardedReproduciblyAsyncResultAnalyzer.cs new file mode 100644 index 00000000..d4364f29 --- /dev/null +++ b/JustDummies.Analyzers/DiscardedReproduciblyAsyncResultAnalyzer.cs @@ -0,0 +1,58 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD002 — reports a call to Any.ReproduciblyAsync(...) whose returned +/// is discarded (the call stands alone as a statement, or is assigned to _). The task faults with the body's +/// exception; discarding it lets a failing test pass green. Await it. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DiscardedReproduciblyAsyncResultAnalyzer : DiagnosticAnalyzer { + + private const string AnyMetadataName = "JustDummies.Any"; + private const string ReproduciblyAsyncMethodName = "ReproduciblyAsync"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DiscardedReproduciblyAsyncResult); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? anyType = context.Compilation.GetTypeByMetadataName(AnyMetadataName); + if (anyType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, anyType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol anyType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != ReproduciblyAsyncMethodName || !SymbolEqualityComparer.Default.Equals(method.ContainingType, anyType)) { return; } + if (!IsResultDiscarded(invocation)) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DiscardedReproduciblyAsyncResult, invocation.Syntax.GetLocation())); + } + + // The result is thrown away either when the call stands alone as a statement or when it is explicitly discarded + // (`_ = Any.ReproduciblyAsync(...);`). Either way the body's failures are lost. + private static bool IsResultDiscarded(IInvocationOperation invocation) { + return invocation.Parent switch { + IExpressionStatementOperation => true, + ISimpleAssignmentOperation { Target: IDiscardOperation } => true, + _ => false, + }; + } + +} diff --git a/JustDummies.Analyzers/HelpLinks.cs b/JustDummies.Analyzers/HelpLinks.cs new file mode 100644 index 00000000..f73a9f62 --- /dev/null +++ b/JustDummies.Analyzers/HelpLinks.cs @@ -0,0 +1,15 @@ +namespace JustDummies.Analyzers; + +/// +/// Builds the documentation URL surfaced by each diagnostic (the "help link" in the IDE). Per-rule pages live +/// under doc/handwritten/for-users/analyzers/. +/// +internal static class HelpLinks { + + private const string Base = "https://github.com/Reefact/first-class-errors/blob/main/doc/handwritten/for-users/analyzers"; + + public static string For(string diagnosticId) { + return $"{Base}/{diagnosticId}.en.md"; + } + +} diff --git a/JustDummies.Analyzers/JustDummies.Analyzers.csproj b/JustDummies.Analyzers/JustDummies.Analyzers.csproj new file mode 100644 index 00000000..33fcf7d5 --- /dev/null +++ b/JustDummies.Analyzers/JustDummies.Analyzers.csproj @@ -0,0 +1,36 @@ + + + + + netstandard2.0 + enable + enable + latest + + + true + + + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/JustDummies.UnitTests/SeedReproducibilityTests.cs b/JustDummies.UnitTests/SeedReproducibilityTests.cs index fc4fd175..c93ea035 100644 --- a/JustDummies.UnitTests/SeedReproducibilityTests.cs +++ b/JustDummies.UnitTests/SeedReproducibilityTests.cs @@ -93,12 +93,12 @@ public void ReproduciblyWithoutSeedStillReportsAReplayableSeed() { Check.That(reported!).Contains("Any.Reproducibly("); } - [Fact(DisplayName = "The async Reproducibly reports the seed and rethrows on failure.")] + [Fact(DisplayName = "The async ReproduciblyAsync reports the seed and rethrows on failure.")] public async Task AsyncReproduciblyReportsTheSeedAndRethrows() { string? reported = null; await Assert.ThrowsAsync( - () => Any.Reproducibly(7, async () => { + () => Any.ReproduciblyAsync(7, async () => { await Task.Yield(); throw new InvalidOperationException("boom"); @@ -108,16 +108,16 @@ await Assert.ThrowsAsync( Check.That(reported!).Contains("7"); } - [Fact(DisplayName = "The async Reproducibly with a given seed replays the same sequence of values.")] + [Fact(DisplayName = "The async ReproduciblyAsync with a given seed replays the same sequence of values.")] public async Task AsyncReproduciblyWithASeedIsDeterministic() { string first = string.Empty; string second = string.Empty; - await Any.Reproducibly(4321, async () => { + await Any.ReproduciblyAsync(4321, async () => { await Task.Yield(); first = Batch(); }); - await Any.Reproducibly(4321, async () => { + await Any.ReproduciblyAsync(4321, async () => { await Task.Yield(); second = Batch(); }); @@ -125,10 +125,10 @@ await Any.Reproducibly(4321, async () => { Check.That(second).IsEqualTo(first); } - [Fact(DisplayName = "Reproducibly requires a body.")] + [Fact(DisplayName = "Reproducibly and ReproduciblyAsync require a body.")] public void ReproduciblyRequiresABody() { Check.ThatCode(() => Any.Reproducibly((Action)null!)).Throws(); - Check.ThatCode(() => Any.Reproducibly((Func)null!)).Throws(); + Check.ThatCode(() => Any.ReproduciblyAsync((Func)null!)).Throws(); } } diff --git a/JustDummies/Any.cs b/JustDummies/Any.cs index b8594bbf..c509326f 100644 --- a/JustDummies/Any.cs +++ b/JustDummies/Any.cs @@ -539,12 +539,18 @@ public static void Reproducibly(int seed, Action body, Action? report = /// Asynchronous counterpart of : awaits /// under a fresh seed and reports it if the body faults. /// + /// + /// The returned task must be awaited. Dropping it silences the body's failures — the assertions run + /// on a continuation after the caller has already moved on, and a discarded fault never reaches the test + /// runner. Discarding it is a compile error (diagnostic JD002); passing an asynchronous body to the + /// synchronous instead is a compile error (JD001). + /// /// The asynchronous test body to run under a reproducible random context. /// The sink the seed is written to on failure. Defaults to when null. - /// A task that completes when completes. + /// A task that completes when completes, and faults with the body's exception. /// Thrown when is null. - public static Task Reproducibly(Func body, Action? report = null) { - return Reproducibly(AmbientRandomSource.NewSeed(), body, report); + public static Task ReproduciblyAsync(Func body, Action? report = null) { + return ReproduciblyAsync(AmbientRandomSource.NewSeed(), body, report); } /// @@ -556,7 +562,7 @@ public static Task Reproducibly(Func body, Action? report = null) /// The sink the seed is written to on failure. Defaults to when null. /// A task that completes when completes. /// Thrown when is null. - public static async Task Reproducibly(int seed, Func body, Action? report = null) { + public static async Task ReproduciblyAsync(int seed, Func body, Action? report = null) { if (body is null) { throw new ArgumentNullException(nameof(body)); } using (AmbientRandomSource.UseSeed(seed)) { diff --git a/JustDummies/JustDummies.csproj b/JustDummies/JustDummies.csproj index 1c7c7948..3cf890e1 100644 --- a/JustDummies/JustDummies.csproj +++ b/JustDummies/JustDummies.csproj @@ -57,9 +57,10 @@ - + + + + + + + $(TargetsForTfmSpecificContentInPackage);_AddAnalyzerToPackage + + + + + + + analyzers/dotnet/cs + + + + diff --git a/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt index e14b7250..85eda0af 100644 --- a/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/JustDummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -473,9 +473,9 @@ static JustDummies.Any.ListOf(JustDummies.IAny! item) -> JustDummies.AnyLi static JustDummies.Any.OneOf(params T[]! values) -> JustDummies.AnyOneOf! static JustDummies.Any.PairOf(JustDummies.IAny! first, JustDummies.IAny! second) -> JustDummies.IAny<(T1, T2)>! static JustDummies.Any.Reproducibly(int seed, System.Action! body, System.Action? report = null) -> void -static JustDummies.Any.Reproducibly(int seed, System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! static JustDummies.Any.Reproducibly(System.Action! body, System.Action? report = null) -> void -static JustDummies.Any.Reproducibly(System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! +static JustDummies.Any.ReproduciblyAsync(int seed, System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! +static JustDummies.Any.ReproduciblyAsync(System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! static JustDummies.Any.SByte() -> JustDummies.AnySByte! static JustDummies.Any.SequenceOf(JustDummies.IAny! item) -> JustDummies.AnySequence! static JustDummies.Any.SetOf(JustDummies.IAny! item) -> JustDummies.AnySet! diff --git a/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index acd4e340..4dd0f134 100644 --- a/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/JustDummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -402,9 +402,9 @@ static JustDummies.Any.ListOf(JustDummies.IAny! item) -> JustDummies.AnyLi static JustDummies.Any.OneOf(params T[]! values) -> JustDummies.AnyOneOf! static JustDummies.Any.PairOf(JustDummies.IAny! first, JustDummies.IAny! second) -> JustDummies.IAny<(T1, T2)>! static JustDummies.Any.Reproducibly(int seed, System.Action! body, System.Action? report = null) -> void -static JustDummies.Any.Reproducibly(int seed, System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! static JustDummies.Any.Reproducibly(System.Action! body, System.Action? report = null) -> void -static JustDummies.Any.Reproducibly(System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! +static JustDummies.Any.ReproduciblyAsync(int seed, System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! +static JustDummies.Any.ReproduciblyAsync(System.Func! body, System.Action? report = null) -> System.Threading.Tasks.Task! static JustDummies.Any.SByte() -> JustDummies.AnySByte! static JustDummies.Any.SequenceOf(JustDummies.IAny! item) -> JustDummies.AnySequence! static JustDummies.Any.SetOf(JustDummies.IAny! item) -> JustDummies.AnySet! diff --git a/build/stryker/justdummies-analyzers.json b/build/stryker/justdummies-analyzers.json new file mode 100644 index 00000000..dea7fbc6 --- /dev/null +++ b/build/stryker/justdummies-analyzers.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "JustDummies.Analyzers.csproj", + "test-projects": [ + "JustDummies.Analyzers.UnitTests/JustDummies.Analyzers.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 60, + "break": 0 + } + } +} diff --git a/doc/handwritten/for-maintainers/adr/0044-ship-justdummies-analyzers.fr.md b/doc/handwritten/for-maintainers/adr/0044-ship-justdummies-analyzers.fr.md new file mode 100644 index 00000000..6848492e --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0044-ship-justdummies-analyzers.fr.md @@ -0,0 +1,137 @@ +# ADR-0044 | Fournir des analyseurs JustDummies de première partie, et garder avec eux la surface asynchrone reproductible + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0044-ship-justdummies-analyzers.md) + +**Statut :** Proposé +**Date :** 2026-07-27 +**Décideurs :** Reefact + +## Contexte + +* `JustDummies` est une bibliothèque de support de test : toute sa valeur tient à ce qu'un arrangement cassé + *échoue*. `Any.Reproducibly` exécute un corps de test sous une graine épinglée et la rapporte en cas d'échec. Elle + surchargeait sur un seul nom une `Action` synchrone et un `Func` asynchrone. +* Ce jeu de surcharges cachait un piège à échec silencieux. Une lambda `async` est une meilleure conversion vers + `Func` que vers `Action`, donc `Any.Reproducibly(async () => { ... })` se liait à la surcharge asynchrone, qui + retournait un `Task`. Une méthode de test est en général un `void` synchrone : le `Task` retourné était jeté ; les + assertions du corps s'exécutaient sur une continuation après le retour de la méthode, et l'échec ne surgissait — au + mieux — que plus tard sous forme d'`UnobservedTaskException`. **Le test passait au vert.** Le `CS4014` natif du + compilateur ne se déclenche pas dans une méthode synchrone : rien n'avertissait. +* Renommer la surcharge asynchrone en `ReproduciblyAsync`, conforme au TAP, corrige le nommage, mais seul il *rouvre* + le piège de l'autre côté : `Reproducibly` n'ayant plus que des surcharges `Action`, une lambda `async` se lie à + `Action` en **`async void`**, dont l'exception d'après le premier `await` échappe entièrement au `try/catch` de la + portée reproductible. +* C# n'offre aucun `Task` non-jetable, ni aucun moyen d'interdire une conversion lambda-`async`→`Action`. Les deux + erreurs résiduelles — passer un corps async à `Reproducibly`, et jeter un `Task` de `ReproduciblyAsync` — ne sont + donc pas exprimables dans le système de types. +* `FirstClassErrors` fournit déjà des analyseurs Roslyn (`FCE001`…`FCE022`) dans son propre package NuGet. + `JustDummies` n'en fournissait aucun, et c'est une bibliothèque **autonome, agnostique des erreurs** (un ADR le + garde : elle ne doit jamais dépendre de FirstClassErrors). Une règle propre à JustDummies ne peut pas vivre dans + `FirstClassErrors.Analyzers` — cet assembly est livré dans le package FirstClassErrors et porte l'identité FCE — un + consommateur de JustDummies ne la recevrait jamais. +* D'autres gardes ont été envisagées et rejetées : une surcharge « poison » `[Obsolete(error: true)]` (un membre + déprécié dans une 1.0 toute neuve est un contresens qui salit la surface livrée), et une surcharge asynchrone qui + bloque avec `GetAwaiter().GetResult()` (le sync-over-async risque le *deadlock* sous un `SynchronizationContext` + capturé — l'anti-pattern que l'async existe pour éviter). + +## Décision + +`JustDummies` fournit ses propres analyseurs Roslyn de première partie, dans un nouveau projet `JustDummies.Analyzers` +empaqueté dans le package NuGet `JustDummies` (`analyzers/dotnet/cs`), agnostique des erreurs et indépendant de +`FirstClassErrors`, sous un schéma d'identifiants de diagnostic propre à JustDummies (`JDxxx`, en miroir de `FCExxx`). + +La première application rend la surface asynchrone reproductible non-abusable : le point d'entrée asynchrone est +`Any.ReproduciblyAsync(Func)` (nommé TAP, retourne un `Task` que l'on `await`), le synchrone reste +`Any.Reproducibly(Action)`, et deux analyseurs de sévérité *error* ferment ce que les types ne peuvent pas — **JD001**, +une lambda `async` passée à `Any.Reproducibly`, et **JD002**, un `Task` de `Any.ReproduciblyAsync` jeté. + +## Justification + +* Le défaut est invisible là où ça compte le plus — une compilation qui passe sur un test qui échoue — donc une erreur + de compilation est la seule contrainte assez forte. Un avertissement, ou de la documentation, laisse le vert vert. +* Le choix de la contrainte suit ce que chaque mécanisme peut porter (le même grain qu'ADR-0035). Le système de types + *ne peut pas* exprimer « ce `Task` doit être attendu » ni « cette lambda async ne doit pas se lier ici », donc un + analyseur est l'outil légitime — pas un pis-aller, le seul mécanisme disponible. Là où le langage *peut* porter la + règle, on le préfère ; ici il ne peut pas. +* Un analyseur de première partie n'est pas exotique pour ce dépôt — il en livre et en teste déjà, avec un contrat de + chargement Roslyn épinglé au plancher et des règles à suivi de version. Étendre cette discipline à JustDummies + réutilise un patron éprouvé plutôt que d'en inventer un, et garde les règles JustDummies dans le package JustDummies, + là où est leur public. +* Les alternatives rejetées échangent chacune le vert-silencieux contre un échec pire ou plus laid : la surcharge + poison livre un membre déprécié dès le premier jour ; la surcharge bloquante échange un vert-silencieux contre un + *deadlock* possible. L'analyseur laisse la surface publique propre (deux méthodes honnêtes) et l'échec bruyant (une + erreur de compilation). +* Séparer `Reproducibly`/`ReproduciblyAsync` par le nom — plutôt que de garder un seul nom surchargé — est ce qui rend + JD001 et JD002 précis : chaque règle vise une seule méthode, donc aucune ne se déclenche à tort sur l'usage correct + de l'autre. + +## Alternatives considérées + +### Garder `Reproducibly(Func)` surchargé et n'ajouter qu'un analyseur « ne pas jeter » + +Envisagé car c'est le plus petit changement, une seule règle. Rejeté car il laisse une méthode qui retourne un `Task` +sans le suffixe `Async` (violation TAP et smell de nommage durable, figé à 1.0), et car la surcharge qui retourne un +`Task` jetable est précisément la forme qu'exploite le piège — le choix de nommage et le choix de sûreté se font mieux +ensemble. + +### Surcharge poison — `[Obsolete("Use ReproduciblyAsync", error: true)] Reproducibly(Func)` + +Envisagé car il ferme le piège `async void` purement au niveau langage, sans analyseur. Rejeté car `[Obsolete]` +signifie « déprécié depuis une version antérieure », dont une 1.0 neuve n'a aucune ; il livre un membre à jamais +non-appelable dans la toute première surface publique, ce qui se lit comme une erreur plutôt qu'un design. + +### Surcharge asynchrone bloquante — `void Reproducibly(Func)` qui exécute le corps via `GetAwaiter().GetResult()` + +Envisagé car il n'expose aucun `Task` à jeter et ne nécessite aucun analyseur. Rejeté car il impose du +sync-over-async à tout corps de test asynchrone, ce qui peut *deadlocker* sous un `SynchronizationContext` capturé ; +échanger un vert-silencieux contre un gel intermittent n'est pas un progrès pour un outil de test. + +### Mettre la règle JustDummies dans `FirstClassErrors.Analyzers` + +Envisagé car le projet d'analyseur existe déjà. Rejeté car cet assembly est livré dans le package FirstClassErrors et +porte l'identité FCE : un consommateur de JustDummies seul ne recevrait jamais la règle, et faire transiter une règle +JustDummies par la bibliothèque d'erreurs casse la frontière d'autonomie que garde le test d'architecture. + +## Conséquences + +### Positives + +* Le piège du vert-silencieux devient une erreur de compilation : `Any.Reproducibly(async …)` (JD001) et un + `Any.ReproduciblyAsync(…)` jeté (JD002) font tous deux échouer la build, avec un message pointant vers la correction. +* Le point d'entrée asynchrone est nommé TAP (`ReproduciblyAsync`), donc il se lit correctement et `CS4014` couvre + gratuitement le cas `await`-dans-une-méthode-async. +* JustDummies acquiert une histoire d'analyseurs de première partie extensible à de futures règles, dans son propre + package, sans couplage à FirstClassErrors. + +### Négatives + +* Le renommage est un changement cassant de la surface publique (pré-version, non livrée) : `Reproducibly(Func)` + devient `ReproduciblyAsync`. Acceptable seulement dans la fenêtre pré-1.0, sans coût de migration puisqu'il n'y a + aucun consommateur. +* Un deuxième projet d'analyseur, une cible d'empaquetage et un schéma d'ID de diagnostic alourdissent le dépôt et la + build du package JustDummies. + +### Risques + +* Le contrat de chargement de `JustDummies.Analyzers` doit rester épinglé au plancher Roslyn, comme + `FirstClassErrors.Analyzers`, sinon l'analyseur échoue silencieusement à se charger (CS8032) sur des SDK plus + anciens ; atténué en épinglant `Microsoft.CodeAnalysis.CSharp` à `$(RoslynFloorVersion)`. +* JD001/JD002 détectent l'invocation par le nom de métadonnées `JustDummies.Any` et le nom de méthode ; un futur + renommage de ces membres désactiverait silencieusement les règles, donc leurs noms font désormais partie du contrat + de diagnostic. + +## Actions de suivi + +* Aucune requise pour la surface reproductible. Appliquer le même patron d'analyseur de première partie quand une + future erreur JustDummies n'est exprimable qu'à la compilation. +* La provenance des messages de conflit d'`AnyEnum` / `AnyGuid` (issue #314) est sans rapport et non affectée. + +## Références + +* ADR-0035 — imposer les conflits Any structurels à la compilation, ceux dépendant de la valeur à l'exécution ; le + grain « les types là où ils peuvent porter la règle, des vérifications là où ils ne peuvent pas » que suit cette + décision. +* ADR-0031 — nommer les fabriques d'Any d'après leur type CLR ; précédent pour « rendre la règle in-cassable plutôt + que seulement vérifiée », et pour la discipline de nommage TAP sur la surface. +* ADR-0042 — sérialiser les tirages sur une source aléatoire ; le correctif de reproductibilité frère (#310 / #311). +* Issue #317 — le piège du vert-silencieux que cet ADR résout. diff --git a/doc/handwritten/for-maintainers/adr/0044-ship-justdummies-analyzers.md b/doc/handwritten/for-maintainers/adr/0044-ship-justdummies-analyzers.md new file mode 100644 index 00000000..fc6d7dc0 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0044-ship-justdummies-analyzers.md @@ -0,0 +1,129 @@ +# ADR-0044 | Ship first-party JustDummies analyzers, and guard the reproducible async surface with them + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0044-ship-justdummies-analyzers.fr.md) + +**Status:** Proposed +**Date:** 2026-07-27 +**Decision Makers:** Reefact + +## Context + +* `JustDummies` is a test-support library: its whole value is that a broken arrangement *fails*. `Any.Reproducibly` + runs a test body under a pinned seed and reports it on failure. It overloaded a synchronous `Action` and an + asynchronous `Func` on one name. +* That overload set had a silent-failure footgun. An `async` lambda is a better conversion to `Func` than to + `Action`, so `Any.Reproducibly(async () => { ... })` bound to the async overload, which returned a `Task`. A test + method is usually a synchronous `void`, so the returned task was discarded; the body's assertions ran on a + continuation after the method had already returned, and the failure surfaced — if at all — as a later + `UnobservedTaskException`. **The test passed green.** The compiler's own `CS4014` does not fire in a synchronous + method, so nothing warned. +* Renaming the async overload to a TAP-conventional `ReproduciblyAsync` fixes the naming, but on its own it *reopens* + the hazard from the other side: with only `Action` overloads left on `Reproducibly`, an `async` lambda binds to + `Action` as **`async void`**, whose post-await exception escapes the reproducible scope's `try/catch` entirely. +* C# offers no non-droppable `Task`, and no way to forbid an `async`-lambda→`Action` conversion. The two residual + mistakes — passing an async body to `Reproducibly`, and discarding a `ReproduciblyAsync` task — are therefore not + expressible in the type system. +* `FirstClassErrors` already ships Roslyn analyzers (`FCE001`…`FCE022`) inside its own NuGet package. `JustDummies` + shipped none, and is a **standalone, error-agnostic** library (ADR guards it: it must never depend on + FirstClassErrors). A JustDummies-specific rule cannot live in `FirstClassErrors.Analyzers` — that assembly ships in + the FirstClassErrors package and carries the FCE identity — so a JustDummies consumer would never receive it. +* Alternative guards were considered and rejected in the design discussion: an `[Obsolete(error: true)]` "poison" + overload (a member deprecated in a brand-new 1.0 is a contradiction that clutters the shipped surface), and an + async overload that blocks with `GetAwaiter().GetResult()` (sync-over-async risks deadlock under a captured + `SynchronizationContext` — the anti-pattern async exists to avoid). + +## Decision + +`JustDummies` ships its own first-party Roslyn analyzers, in a new `JustDummies.Analyzers` project packaged inside the +`JustDummies` NuGet package (`analyzers/dotnet/cs`), error-agnostic and independent of `FirstClassErrors`, under a +JustDummies-owned diagnostic-id scheme (`JDxxx`, mirroring `FCExxx`). + +The first application makes the reproducible async surface un-misusable: the asynchronous entry point is +`Any.ReproduciblyAsync(Func)` (TAP-named, returns an awaited `Task`), the synchronous one stays +`Any.Reproducibly(Action)`, and two error-severity analyzers close the mistakes the types cannot — **JD001**, an +`async` lambda passed to `Any.Reproducibly`, and **JD002**, a discarded `Any.ReproduciblyAsync` task. + +## Rationale + +* The defect is invisible where it matters most — a passing build over a failing test — so a build-time error is the + only enforcement strong enough. A warning, or documentation, leaves the green build green. +* The choice of enforcement follows what each mechanism can carry (the same grain as ADR-0035). The type system + *cannot* express "this `Task` must be awaited" or "this async lambda must not bind here", so a run-time or + compile-time analyzer is the legitimate tool — not a fallback, the only mechanism available. Where the language + *can* carry the rule, it is preferred; here it cannot. +* A first-party analyzer is not exotic for this repository — it already ships and tests analyzers, with a floor-pinned + Roslyn load contract and release-tracked rules. Extending that discipline to JustDummies reuses a proven pattern + rather than inventing one, and keeps the JustDummies rules in the JustDummies package where their audience is. +* The rejected alternatives each trade the silent-green for a worse or uglier failure: the poison overload ships a + deprecated member on day one; the blocking overload trades a silent green for a possible deadlock. The analyzer + leaves the public surface clean (two honest methods) and the failure mode loud (a compile error). +* Splitting `Reproducibly`/`ReproduciblyAsync` by name — rather than keeping one overloaded name — is what lets JD001 + and JD002 be precise: each rule targets one method, so neither over-reports on the other's correct use. + +## Alternatives Considered + +### Keep the overloaded `Reproducibly(Func)` and add only a "don't discard" analyzer + +Considered because it is the smallest change and needs a single rule. Rejected because it leaves a `Task`-returning +method without the `Async` suffix (a TAP violation and a lasting naming smell frozen at 1.0), and because the overload +that returns a droppable `Task` is exactly the shape the footgun exploits — the naming choice and the safety choice +are better made together. + +### Poison overload — `[Obsolete("Use ReproduciblyAsync", error: true)] Reproducibly(Func)` + +Considered because it closes the `async void` hazard purely in the language, with no analyzer. Rejected because +`[Obsolete]` means "deprecated since an earlier version", of which a fresh 1.0 has none; it ships a permanently +un-callable member in the very first public surface, which reads as an error rather than a design. + +### Blocking async overload — `void Reproducibly(Func)` that runs the body with `GetAwaiter().GetResult()` + +Considered because it exposes no `Task` to drop and needs no analyzer. Rejected because it forces sync-over-async on +every asynchronous test body, which can deadlock under a captured `SynchronizationContext`; trading a silent green for +an intermittent hang is not an improvement for a test tool. + +### Put the JustDummies rule in `FirstClassErrors.Analyzers` + +Considered because the analyzer project already exists. Rejected because that assembly ships inside the +FirstClassErrors package and carries the FCE identity: a JustDummies-only consumer would never receive the rule, and +routing a JustDummies rule through the error library breaks the standalone boundary the architecture test guards. + +## Consequences + +### Positive + +* The silent-green footgun becomes a compile error: `Any.Reproducibly(async …)` (JD001) and a discarded + `Any.ReproduciblyAsync(…)` (JD002) both fail the build, with a message pointing at the fix. +* The async entry point is TAP-named (`ReproduciblyAsync`), so it reads correctly and `CS4014` covers the + await-in-async-method case for free. +* JustDummies gains a first-party analyzer story it can extend to future rules, in its own package, without coupling + to FirstClassErrors. + +### Negative + +* The rename is a breaking change to the (pre-release, unshipped) public surface: `Reproducibly(Func)` becomes + `ReproduciblyAsync`. Acceptable only in the pre-1.0 window, at no migration cost since there are no consumers. +* A second analyzer project, package-embedding target, and diagnostic-id scheme enlarge the repository and the + JustDummies package's build. + +### Risks + +* The `JustDummies.Analyzers` load contract must stay pinned to the Roslyn floor, like `FirstClassErrors.Analyzers`, + or the analyzer silently fails to load (CS8032) on older SDKs; mitigated by pinning `Microsoft.CodeAnalysis.CSharp` + to `$(RoslynFloorVersion)`. +* JD001/JD002 detect the invocation by the `JustDummies.Any` metadata name and the method name; a future rename of + those members would silently disable the rules, so their names are now part of the diagnostic contract. + +## Follow-up Actions + +* None required for the reproducible surface. Apply the same first-party-analyzer pattern when a future JustDummies + mistake is expressible only at compile time. +* `AnyEnum` / `AnyGuid` conflict-message provenance (issue #314) is unrelated and unaffected. + +## References + +* ADR-0035 — enforce structural Any conflicts at compile time, value-dependent ones at run time; the "types where + they can carry the rule, checks where they cannot" grain this decision follows. +* ADR-0031 — name Any's factories after their CLR type; precedent for "make the rule un-break-able rather than merely + checked", and for TAP-style naming discipline on the surface. +* ADR-0042 — serialize draws on a random source; the sibling reproducibility fix (#310 / #311). +* Issue #317 — the silent-green footgun this ADR resolves. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 125aa306..f2494f42 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -226,3 +226,4 @@ Optional supporting material: | [ADR-0041](0041-draw-flag-enum-combinations-behind-an-opt-in.md) | Draw flag-enum combinations behind an opt-in | Accepted | | [ADR-0042](0042-serialize-draws-on-a-random-source.md) | Serialize draws on a random source, and scope reproducibility to the draw sequence | Proposed | | [ADR-0043](0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) | Gate pull requests on the mutation score of what they changed | Proposed | +| [ADR-0044](0044-ship-justdummies-analyzers.md) | Ship first-party JustDummies analyzers, and guard the reproducible async surface with them | Proposed | diff --git a/doc/handwritten/for-users/ArbitraryTestValues.en.md b/doc/handwritten/for-users/ArbitraryTestValues.en.md index 3fba3155..8b351bb4 100644 --- a/doc/handwritten/for-users/ArbitraryTestValues.en.md +++ b/doc/handwritten/for-users/ArbitraryTestValues.en.md @@ -85,7 +85,7 @@ JustDummies.Any.Reproducibly(1234, () => { }); ``` -Reproducing a run needs the same sequence of draws, so a body whose order depends on non-deterministic external state is not fully replayable from the seed alone. There is also an asynchronous overload, `JustDummies.Any.Reproducibly(Func)`, for `async` test bodies. Because the factories, the primitives, and the clock and id seams below all draw from the same ambient source, one `Reproducibly` scope replays them together. +Reproducing a run needs the same sequence of draws, so a body whose order depends on non-deterministic external state is not fully replayable from the seed alone. There is also an asynchronous form, `JustDummies.Any.ReproduciblyAsync(Func)`, for `async` test bodies — await it, or the body's failures are silently dropped (the analyzer enforces this). Because the factories, the primitives, and the clock and id seams below all draw from the same ambient source, one `Reproducibly` scope replays them together. ### Pinning the seed without a body to wrap diff --git a/doc/handwritten/for-users/ArbitraryTestValues.fr.md b/doc/handwritten/for-users/ArbitraryTestValues.fr.md index 5635d215..4bed5e47 100644 --- a/doc/handwritten/for-users/ArbitraryTestValues.fr.md +++ b/doc/handwritten/for-users/ArbitraryTestValues.fr.md @@ -85,7 +85,7 @@ JustDummies.Any.Reproducibly(1234, () => { }); ``` -Reproduire une exécution nécessite la **même séquence** de tirages : un corps dont l’ordre dépend d’un état externe non déterministe n’est pas entièrement rejouable à partir de la seule graine. Une surcharge asynchrone, `JustDummies.Any.Reproducibly(Func)`, existe pour les corps de test `async`. Comme les fabriques, les primitives et les seams d’horloge et d’identifiants ci-dessous tirent tous de la même source ambiante, un seul `Reproducibly` les rejoue ensemble. +Reproduire une exécution nécessite la **même séquence** de tirages : un corps dont l’ordre dépend d’un état externe non déterministe n’est pas entièrement rejouable à partir de la seule graine. Une forme asynchrone, `JustDummies.Any.ReproduciblyAsync(Func)`, existe pour les corps de test `async` — attendez-la (`await`), sinon les échecs du corps sont silencieusement perdus (l'analyseur l'impose). Comme les fabriques, les primitives et les seams d’horloge et d’identifiants ci-dessous tirent tous de la même source ambiante, un seul `Reproducibly` les rejoue ensemble. ### Fixer la graine sans corps à envelopper diff --git a/doc/handwritten/for-users/analyzers/JD001.en.md b/doc/handwritten/for-users/analyzers/JD001.en.md new file mode 100644 index 00000000..281a1639 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD001.en.md @@ -0,0 +1,44 @@ +# JD001: AsyncBodyPassedToReproducibly + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD001.fr.md) + +| | | +|---|---| +| **Category** | Reproducibility (`JustDummies.Reproducibility`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +`Any.Reproducibly` takes a synchronous `Action`. An `async` lambda bound to it becomes `async void`: the body runs to its first `await`, then its continuation — every assertion after that `await` — runs *after* the call has already returned, and the exception escapes the reproducible scope entirely. The test passes green even though the body failed. + +Pass the asynchronous body to `Any.ReproduciblyAsync(Func)` and `await` it. For a synchronous body, keep `Any.Reproducibly(() => { ... })`. + +## Noncompliant + +```csharp +[Fact] +public void Prices_are_positive() { + Any.Reproducibly(async () => { // JD001: the async body runs as 'async void' + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); // this failure never reaches the test runner + }); +} +``` + +## Compliant + +```csharp +[Fact] +public async Task Prices_are_positive() { + await Any.ReproduciblyAsync(async () => { + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); + }); +} +``` + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD001.fr.md b/doc/handwritten/for-users/analyzers/JD001.fr.md new file mode 100644 index 00000000..851364f4 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD001.fr.md @@ -0,0 +1,44 @@ +# JD001 : AsyncBodyPassedToReproducibly + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD001.en.md) + +| | | +|---|---| +| **Catégorie** | Reproductibilité (`JustDummies.Reproducibility`) | +| **Sévérité** | 🔴 Erreur | +| **Activée par défaut** | Oui | + +`Any.Reproducibly` prend une `Action` synchrone. Une lambda `async` qui s'y lie devient `async void` : le corps s'exécute jusqu'à son premier `await`, puis sa continuation — chaque assertion après cet `await` — s'exécute *après* le retour de l'appel, et l'exception échappe entièrement à la portée reproductible. Le test passe au vert alors même que le corps a échoué. + +Passez le corps asynchrone à `Any.ReproduciblyAsync(Func)` et faites `await`. Pour un corps synchrone, gardez `Any.Reproducibly(() => { ... })`. + +## Non conforme + +```csharp +[Fact] +public void Prices_are_positive() { + Any.Reproducibly(async () => { // JD001 : le corps async s'exécute en 'async void' + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); // cet échec n'atteint jamais le lanceur de tests + }); +} +``` + +## Conforme + +```csharp +[Fact] +public async Task Prices_are_positive() { + await Any.ReproduciblyAsync(async () => { + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); + }); +} +``` + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD002.en.md b/doc/handwritten/for-users/analyzers/JD002.en.md new file mode 100644 index 00000000..d3b5e4ce --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD002.en.md @@ -0,0 +1,48 @@ +# JD002: DiscardedReproduciblyAsyncResult + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD002.fr.md) + +| | | +|---|---| +| **Category** | Reproducibility (`JustDummies.Reproducibility`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +`Any.ReproduciblyAsync` returns a `Task` that faults with the body's exception. Discarding it — as a standalone statement, or via `_ =` — lets a failing test pass green, because the failure is never observed. `await` the returned task. + +The compiler's own `CS4014` does not catch this in a synchronous (`void`) test method, which is exactly where the mistake is easiest to make. + +## Noncompliant + +```csharp +[Fact] +public void Prices_are_positive() { + Any.ReproduciblyAsync(async () => { // JD002: the returned task is discarded + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); + }); +} +``` + +```csharp +_ = Any.ReproduciblyAsync(async () => { /* ... */ }); // JD002: the returned task is discarded +``` + +## Compliant + +```csharp +[Fact] +public async Task Prices_are_positive() { + await Any.ReproduciblyAsync(async () => { + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); + }); +} +``` + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD002.fr.md b/doc/handwritten/for-users/analyzers/JD002.fr.md new file mode 100644 index 00000000..072bddbe --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD002.fr.md @@ -0,0 +1,48 @@ +# JD002 : DiscardedReproduciblyAsyncResult + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD002.en.md) + +| | | +|---|---| +| **Catégorie** | Reproductibilité (`JustDummies.Reproducibility`) | +| **Sévérité** | 🔴 Erreur | +| **Activée par défaut** | Oui | + +`Any.ReproduciblyAsync` retourne un `Task` qui échoue avec l'exception du corps. Le jeter — en instruction isolée, ou via `_ =` — laisse un test défaillant passer au vert, parce que l'échec n'est jamais observé. Faites `await` sur le `Task` retourné. + +Le `CS4014` natif du compilateur ne l'attrape pas dans une méthode de test synchrone (`void`), précisément là où l'erreur est la plus facile à commettre. + +## Non conforme + +```csharp +[Fact] +public void Prices_are_positive() { + Any.ReproduciblyAsync(async () => { // JD002 : le Task retourné est jeté + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); + }); +} +``` + +```csharp +_ = Any.ReproduciblyAsync(async () => { /* ... */ }); // JD002 : le Task retourné est jeté +``` + +## Conforme + +```csharp +[Fact] +public async Task Prices_are_positive() { + await Any.ReproduciblyAsync(async () => { + var price = Any.Decimal().Positive().Generate(); + await _repository.SaveAsync(price); + Assert.True(price > 0); + }); +} +``` + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/README.fr.md b/doc/handwritten/for-users/analyzers/README.fr.md index 991ab31a..2f2ef471 100644 --- a/doc/handwritten/for-users/analyzers/README.fr.md +++ b/doc/handwritten/for-users/analyzers/README.fr.md @@ -1,11 +1,11 @@ -# Analyseurs FirstClassErrors +# Analyseurs 🌍 **Langues:** 🇬🇧 [English](./README.md) | 🇫🇷 Français (ce fichier) -Les analyseurs FirstClassErrors sont des règles Roslyn qui s'exécutent pendant la compilation de votre projet. Ils transforment en diagnostics de compilation des erreurs que le runtime et le pipeline de documentation de FirstClassErrors ne signaleraient sinon que tardivement — voire jamais. Ces règles sont **incluses dans le package NuGet `FirstClassErrors`** : tout projet qui le référence en bénéficie automatiquement, sans installation supplémentaire. +Ce dépôt fournit des règles Roslyn avec deux packages. Elles s'exécutent pendant la compilation et transforment en diagnostics de compilation des erreurs que le runtime et le pipeline de documentation ne signaleraient sinon que tardivement — voire jamais. Les règles **FirstClassErrors** (`FCExxx`) sont incluses dans le package `FirstClassErrors` ; les règles **JustDummies** (`JDxxx`) sont incluses dans le package `JustDummies`. Tout projet qui référence un package en bénéficie automatiquement, sans installation supplémentaire. -Chaque règle a un identifiant stable `FCExxx`. Les erreurs sont des défauts durs ; les avertissements signalent des fautes probables ; les règles d'info sont des conventions, et plusieurs sont opt-in (voir chaque page pour les activer). +Chaque règle a un identifiant stable (`FCExxx` ou `JDxxx`). Les erreurs sont des défauts durs ; les avertissements signalent des fautes probables ; les règles d'info sont des conventions, et plusieurs sont opt-in (voir chaque page pour les activer). ## Codes d'erreur @@ -49,6 +49,15 @@ Chaque règle a un identifiant stable `FCExxx`. Les erreurs sont des défauts du | [FCE021 PreferNonThrowingAlternativeToTry](FCE021.fr.md) | 🟠 Warning | activée | Outcome.Try enveloppe un appel qui a déjà une contrepartie non-levante TryXxx / TryCreate disponible pour le framework cible ; envisagez de mapper son résultat (conseil — à supprimer là où la contrepartie n'est pas un vrai inverse). | | [FCE022 TryCatchesCancellation](FCE022.fr.md) | 🟠 Warning | activée | Outcome.Try lie TException à OperationCanceledException (ou un sous-type) ; Try laisse toujours l'annulation se propager, donc le catch est inatteignable et le mapper ne s'exécute jamais. | +## JustDummies — Reproductibilité + +Ces règles sont incluses dans le package **`JustDummies`** (pas FirstClassErrors) et empêchent un corps de test asynchrone d'avaler silencieusement ses propres échecs. + +| Règle | Sévérité | Défaut | Description | +|-------|----------|--------|-------------| +| [JD001 AsyncBodyPassedToReproducibly](JD001.fr.md) | 🔴 Erreur | on | Une lambda async est passée à `Any.Reproducibly(Action)` synchrone ; liée à une Action elle devient async void et ses échecs ne font jamais échouer le test. Utilisez `Any.ReproduciblyAsync` et faites `await`. | +| [JD002 DiscardedReproduciblyAsyncResult](JD002.fr.md) | 🔴 Erreur | on | Le `Task` retourné par `Any.ReproduciblyAsync` est jeté (instruction isolée ou `_ =`) ; les échecs du corps sont perdus. Faites `await`. | + ## Configuration La sévérité de chaque règle se règle dans `.editorconfig`, par exemple : diff --git a/doc/handwritten/for-users/analyzers/README.md b/doc/handwritten/for-users/analyzers/README.md index 9012272f..1712fa39 100644 --- a/doc/handwritten/for-users/analyzers/README.md +++ b/doc/handwritten/for-users/analyzers/README.md @@ -1,11 +1,11 @@ -# FirstClassErrors analyzers +# Analyzers 🌍 **Languages:** 🇬🇧 English (this file) | 🇫🇷 [Français](./README.fr.md) -The FirstClassErrors analyzers are Roslyn rules that run while your project compiles. They turn mistakes that the FirstClassErrors runtime and documentation pipeline would otherwise report late — or never report at all — into build-time diagnostics. The rules ship **inside the `FirstClassErrors` NuGet package**: any project that references it picks them up automatically, with no extra install. +This repository ships Roslyn rules with two packages. They run while your project compiles, turning mistakes that the runtime and documentation pipeline would otherwise report late — or never at all — into build-time diagnostics. The **FirstClassErrors** rules (`FCExxx`) ship inside the `FirstClassErrors` package; the **JustDummies** rules (`JDxxx`) ship inside the `JustDummies` package. Any project that references a package picks up its rules automatically, with no extra install. -Each rule has a stable id `FCExxx`. Errors are hard defects; warnings flag likely mistakes; the info rules are conventions, and several are opt-in (see each page for how to enable them). +Each rule has a stable id (`FCExxx` or `JDxxx`). Errors are hard defects; warnings flag likely mistakes; the info rules are conventions, and several are opt-in (see each page for how to enable them). ## Error codes @@ -49,6 +49,15 @@ Each rule has a stable id `FCExxx`. Errors are hard defects; warnings flag likel | [FCE021 PreferNonThrowingAlternativeToTry](FCE021.en.md) | 🟠 Warning | on | Outcome.Try wraps a call that already has a non-throwing TryXxx / TryCreate counterpart available for the target framework; consider mapping its result (advisory — suppress where the counterpart is not a true inverse). | | [FCE022 TryCatchesCancellation](FCE022.en.md) | 🟠 Warning | on | Outcome.Try binds TException to OperationCanceledException (or a subtype); Try always lets cancellation propagate, so the catch is unreachable and the mapper never runs. | +## JustDummies — Reproducibility + +These rules ship in the **`JustDummies`** package (not FirstClassErrors) and keep an asynchronous test body from silently swallowing its own failures. + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [JD001 AsyncBodyPassedToReproducibly](JD001.en.md) | 🔴 Error | on | An async lambda is passed to the synchronous Any.Reproducibly(Action); bound to an Action it becomes async void and its failures never fail the test. Use Any.ReproduciblyAsync and await it. | +| [JD002 DiscardedReproduciblyAsyncResult](JD002.en.md) | 🔴 Error | on | The task returned by Any.ReproduciblyAsync is discarded (a bare statement, or `_ =`); the body's failures are lost. Await it. | + ## Configuring Every rule's severity can be tuned in `.editorconfig`, for example: