diff --git a/Directory.Packages.props b/Directory.Packages.props index d1660712..9ff52b83 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,6 +33,7 @@ + diff --git a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs index c6f5af3c..3b61d767 100644 --- a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs +++ b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Reflection; using FirstClassErrors; @@ -9,18 +10,44 @@ namespace FirstClassErrors.Analyzers.UnitTests; /// -/// Minimal in-process harness: compiles a C# snippet against the running runtime plus the FirstClassErrors 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. +/// Minimal in-process harness: compiles a C# snippet against a reference set, 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. /// +/// +/// compiles against the running runtime, the default for the analyzer suite. +/// compiles against the .NET Framework 4.7.2 reference assemblies +/// instead — the analyzed code's target framework, not the test's runtime, is what a framework-aware rule +/// reacts to — so a rule that resolves a counterpart from the compilation (FCE021) can be proven silent where that +/// counterpart does not exist for an older framework. +/// internal static class AnalyzerTestHarness { + private const string Net472ReferenceAssembliesMetadataKey = "Net472ReferenceAssemblies"; + private static readonly ImmutableArray BaseReferences = BuildBaseReferences(); - public static async Task> GetDiagnosticsAsync( - DiagnosticAnalyzer analyzer, - string source, - params string[] enabledDiagnosticIds) { + public static Task> GetDiagnosticsAsync( + DiagnosticAnalyzer analyzer, + string source, + params string[] enabledDiagnosticIds) { + + return RunAsync(analyzer, source, BaseReferences, enabledDiagnosticIds); + } + + public static Task> GetDiagnosticsAgainstNet472Async( + DiagnosticAnalyzer analyzer, + string source, + params string[] enabledDiagnosticIds) { + + return RunAsync(analyzer, source, BuildNet472References(), enabledDiagnosticIds); + } + + private static async Task> RunAsync( + DiagnosticAnalyzer analyzer, + string source, + ImmutableArray references, + string[] enabledDiagnosticIds) { SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source); @@ -35,7 +62,7 @@ public static async Task> GetDiagnosticsAsync( CSharpCompilation compilation = CSharpCompilation.Create( assemblyName: "FirstClassErrors.Analyzers.TestSnippet", syntaxTrees: new[] { syntaxTree }, - references: BaseReferences, + references: references, options: options); CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); @@ -57,10 +84,42 @@ private static ImmutableArray BuildBaseReferences() { } } - // The FirstClassErrors core, so ErrorCode / DomainError / DescribeError resolve inside the snippet. - references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location)); + AddCore(references); return references.ToImmutableArray(); } + private static ImmutableArray BuildNet472References() { + List references = new(); + + // The .NET Framework 4.7.2 reference assemblies (including the netstandard facade, so the netstandard2.0 core + // resolves). The directory is baked in at build time via the Net472ReferenceAssemblies assembly metadata. + foreach (string dll in Directory.EnumerateFiles(Net472ReferenceDirectory(), "*.dll", SearchOption.AllDirectories)) { + try { + references.Add(MetadataReference.CreateFromFile(dll)); + } catch { + // Skip anything Roslyn cannot read as a metadata reference. + } + } + + AddCore(references); + + return references.ToImmutableArray(); + } + + private static void AddCore(List references) { + // The FirstClassErrors core, so Outcome / ErrorCode / DomainError resolve inside the snippet. + references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location)); + } + + private static string Net472ReferenceDirectory() { + foreach (AssemblyMetadataAttribute attribute in typeof(AnalyzerTestHarness).Assembly.GetCustomAttributes()) { + if (attribute.Key == Net472ReferenceAssembliesMetadataKey && !string.IsNullOrWhiteSpace(attribute.Value)) { + return attribute.Value!; + } + } + + throw new InvalidOperationException($"The '{Net472ReferenceAssembliesMetadataKey}' assembly metadata was not found; the net472 reference-assemblies package is not wired into the test project."); + } + } diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce019TryCatchesTooBroadlyTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce019TryCatchesTooBroadlyTests.cs new file mode 100644 index 00000000..0064bd8b --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce019TryCatchesTooBroadlyTests.cs @@ -0,0 +1,111 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce019TryCatchesTooBroadlyTests { + + [Fact] + public async Task Reports_when_the_value_overload_catches_Exception() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE019"); + } + + [Fact] + public async Task Reports_when_the_void_overload_catches_Exception() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static void M() { + Outcome.Try(() => { }, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE019"); + } + + [Fact] + public async Task Reports_when_the_async_overload_catches_Exception() { + const string source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using FirstClassErrors; + + public static class Sample { + public static Task> M() { + return Outcome.Try(ct => Task.FromResult(1), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE019"); + } + + [Fact] + public async Task Does_not_report_when_a_specific_exception_is_caught() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_Try_that_is_not_on_Outcome() { + const string source = """ + using System; + + public static class Other { + public static void Try(Action action) where TException : Exception { } + } + + public static class Sample { + public static void M() { + Other.Try(() => { }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce020TryCatchesRichProtocolExceptionTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce020TryCatchesRichProtocolExceptionTests.cs new file mode 100644 index 00000000..19cce269 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce020TryCatchesRichProtocolExceptionTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce020TryCatchesRichProtocolExceptionTests { + + [Fact] + public async Task Reports_when_Try_catches_HttpRequestException() { + const string source = """ + using System.Net.Http; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE020"); + } + + [Fact] + public async Task Reports_when_Try_catches_a_subtype_of_a_protocol_exception() { + const string source = """ + using System.Net.Http; + using FirstClassErrors; + + public class TransportException : HttpRequestException { } + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE020"); + } + + [Fact] + public async Task Reports_when_Try_catches_SocketException() { + const string source = """ + using System.Net.Sockets; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE020"); + } + + [Fact] + public async Task Does_not_report_a_domain_exception() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_broad_Exception_which_is_FCE019s_concern() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce021FrameworkAwarenessTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce021FrameworkAwarenessTests.cs new file mode 100644 index 00000000..a727b570 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce021FrameworkAwarenessTests.cs @@ -0,0 +1,59 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +/// +/// Proves FCE021's framework-awareness end-to-end by compiling the analyzed snippet against the .NET Framework 4.7.2 +/// reference assemblies (not the running runtime). A framework-aware rule reacts to the analyzed code's target +/// framework, so the same call can be flagged on a modern target and left alone on an older one. +/// +public class Fce021FrameworkAwarenessTests { + + [Fact] + public async Task Reports_int_Parse_against_the_net472_reference_set_because_int_TryParse_exists_there() { + // Control: int.TryParse ships on net472 too, so this still fires against the net472 reference set. It proves the + // compilation resolves and the analyzer runs — which is what makes the silent result below meaningful rather + // than a setup artefact. + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => int.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAgainstNet472Async(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE021"); + } + + [Fact] + public async Task Does_not_report_new_MailAddress_against_the_net472_reference_set_where_TryCreate_does_not_exist() { + // The marquee claim: MailAddress.TryCreate is .NET 5+, so against the net472 reference set no counterpart + // resolves and the rule stays silent — even though the very same call fires on net10 (see the MailAddress case + // in Fce021PreferNonThrowingAlternativeToTryTests.Reports_a_constructor_that_has_a_matching_counterpart). + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => new System.Net.Mail.MailAddress(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAgainstNet472Async(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce021PreferNonThrowingAlternativeToTryTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce021PreferNonThrowingAlternativeToTryTests.cs new file mode 100644 index 00000000..e1d08963 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce021PreferNonThrowingAlternativeToTryTests.cs @@ -0,0 +1,418 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce021PreferNonThrowingAlternativeToTryTests { + + #region Reports — a throwing call whose non-throwing counterpart resolves (regardless of origin) + + [Theory] + [InlineData("int", "int.Parse(raw)")] + [InlineData("double", "double.Parse(raw)")] + [InlineData("System.Guid", "System.Guid.Parse(raw)")] + [InlineData("System.DateTime", "System.DateTime.Parse(raw)")] + [InlineData("System.TimeSpan", "System.TimeSpan.Parse(raw)")] + [InlineData("System.Net.IPAddress", "System.Net.IPAddress.Parse(raw)")] + public async Task Reports_a_BCL_parse_that_has_a_matching_TryParse(string resultType, string call) { + string source = $$""" + using FirstClassErrors; + + public static class Sample { + public static Outcome<{{resultType}}> M(string raw) { + return Outcome.Try<{{resultType}}, System.FormatException>(() => {{call}}, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE021"); + } + + [Theory] + [InlineData("System.Guid", "new System.Guid(raw)")] // ctor -> TryParse + [InlineData("System.Version", "new System.Version(raw)")] // ctor -> TryParse + [InlineData("System.Uri", "new System.Uri(raw, System.UriKind.Absolute)")] // ctor -> TryCreate + [InlineData("System.Net.Mail.MailAddress", "new System.Net.Mail.MailAddress(raw)")] // ctor -> TryCreate + public async Task Reports_a_constructor_that_has_a_matching_counterpart(string resultType, string call) { + string source = $$""" + using FirstClassErrors; + + public static class Sample { + public static Outcome<{{resultType}}> M(string raw) { + return Outcome.Try<{{resultType}}, System.FormatException>(() => {{call}}, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE021"); + } + + [Fact] + public async Task Reports_a_block_bodied_lambda_that_returns_a_single_call() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => { return int.Parse(raw); }, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE021"); + } + + [Fact] + public async Task Reports_a_generic_parse_by_constructing_the_counterpart() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => System.Enum.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE021"); + } + + [Fact] + public async Task Reports_on_a_user_defined_method_with_a_matching_TryParse() { + // The rule no longer cares where the type is declared: a consumer type with a shape-matching pair is flagged too. + const string source = """ + using FirstClassErrors; + + public struct Temperature { + public static Temperature Parse(string s) => default; + public static bool TryParse(string s, out Temperature value) { value = default; return true; } + } + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => Temperature.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE021"); + } + + [Fact] + public async Task Reports_on_a_user_defined_constructor_with_a_matching_TryCreate() { + // Fires even though this TryCreate may not be a behaviour-preserving inverse — that is why the rule is advisory + // and suppressible; the developer confirms and silences it where it does not fit. + const string source = """ + using FirstClassErrors; + + public sealed class Slug { + public Slug(string value) { } + public static bool TryCreate(string value, out Slug result) { result = new Slug(value); return true; } + } + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => new Slug(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE021"); + } + + [Fact] + public async Task Reports_by_default_without_an_explicit_opt_in() { + // FCE021 is on by default (Warning); no .editorconfig opt-in is needed for it to fire. + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => int.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + } + + [Fact] + public async Task Reports_a_message_naming_both_the_call_and_its_counterpart() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => int.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("int.Parse").And.Contains("int.TryParse"); + } + + #endregion + + #region Does not report — no compatible counterpart resolves + + [Fact] + public async Task Does_not_report_a_call_with_no_counterpart() { + const string source = """ + using FirstClassErrors; + + public struct Gadget { + public static Gadget Parse(string s) => default; + } + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => Gadget.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_no_exact_arity_TryParse_exists() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => int.Parse(raw, System.Globalization.NumberStyles.Integer), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_the_counterpart_signature_does_not_match() { + const string source = """ + using FirstClassErrors; + + public struct Foo { + public static Foo Parse(string s) => default; + public static bool TryParse(string s) => true; // no out result: not a drop-in + } + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => Foo.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_the_counterpart_is_an_instance_method() { + const string source = """ + using FirstClassErrors; + + public struct Thing { + public static Thing Parse(string s) => default; + public bool TryParse(string s, out Thing value) { value = default; return true; } // instance, not static + } + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => Thing.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + #endregion + + #region Does not report — the wrapped operation is not a single, static, exact call + + [Fact] + public async Task Does_not_report_an_instance_throwing_call() { + // A static counterpart cannot carry the receiver, so an instance call is never a one-for-one rewrite. + const string source = """ + using FirstClassErrors; + + public class Parser { + public int Read(string s) => 0; + public static bool TryRead(string s, out int value) { value = 0; return true; } + } + + public static class Sample { + public static Outcome M(Parser parser, string raw) { + return Outcome.Try(() => parser.Read(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_construction_with_an_object_initializer() { + const string source = """ + using FirstClassErrors; + + public class Widget { + public int Tag; + public Widget(string s) { } + public static bool TryCreate(string s, out Widget value) { value = new Widget(s); return true; } + } + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => new Widget(raw) { Tag = 1 }, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_lambda_with_more_than_one_statement() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => { + string trimmed = raw.Trim(); + return int.Parse(trimmed); + }, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_call_wrapped_in_a_cast() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => (int)long.Parse(raw), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + #endregion + + #region Does not report — wrong overload or not Outcome.Try + + [Fact] + public async Task Does_not_report_the_async_overload() { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using FirstClassErrors; + + public static class Sample { + public static Task> M(string raw) { + return Outcome.Try(ct => Task.FromResult(int.Parse(raw)), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_the_void_overload() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static Outcome M(string raw) { + return Outcome.Try(() => System.Console.WriteLine(int.Parse(raw)), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_Try_that_is_not_on_Outcome() { + const string source = """ + using System; + + public static class Other { + public static T Try(Func operation, Func onError) where TException : Exception => operation(); + } + + public static class Sample { + public static int M(string raw) { + return Other.Try(() => int.Parse(raw), _ => null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + #endregion + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce022TryCatchesCancellationTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce022TryCatchesCancellationTests.cs new file mode 100644 index 00000000..ce7aadbe --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce022TryCatchesCancellationTests.cs @@ -0,0 +1,171 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce022TryCatchesCancellationTests { + + [Fact] + public async Task Reports_when_the_value_overload_catches_OperationCanceledException() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE022"); + } + + [Fact] + public async Task Reports_when_the_caught_type_is_a_subtype_of_OperationCanceledException() { + // TaskCanceledException : OperationCanceledException, so the same 'is not OperationCanceledException' filter + // rules it out too — the catch is just as unreachable. + const string source = """ + using System.Threading.Tasks; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE022"); + } + + [Fact] + public async Task Reports_when_the_void_overload_catches_OperationCanceledException() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static void M() { + Outcome.Try(() => { }, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE022"); + } + + [Fact] + public async Task Reports_when_the_async_overload_catches_OperationCanceledException() { + const string source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using FirstClassErrors; + + public static class Sample { + public static Task> M() { + return Outcome.Try(ct => Task.FromResult(1), _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE022"); + } + + [Fact] + public async Task Names_the_caught_type_in_the_message() { + const string source = """ + using System.Threading.Tasks; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("TaskCanceledException"); + } + + [Fact] + public async Task Does_not_report_when_the_base_Exception_is_caught() { + // Exception is a base of OperationCanceledException, not a subtype, so the filter can still engage for every + // non-cancellation exception. FCE019 owns that case (too-broad); FCE022 stays silent. + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_an_unrelated_exception_is_caught() { + const string source = """ + using System; + using FirstClassErrors; + + public static class Sample { + public static Outcome M() { + return Outcome.Try(() => 1, _ => (Error)null); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_Try_that_is_not_on_Outcome() { + const string source = """ + using System; + + public static class Other { + public static void Try(Action action) where TException : Exception { } + } + + public static class Sample { + public static void M() { + Other.Try(() => { }); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj b/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj index cf3ef08c..d6f58260 100644 --- a/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj +++ b/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj @@ -20,6 +20,20 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + build;buildTransitive + + + + + + <_Parameter1>Net472ReferenceAssemblies + <_Parameter2>$(PkgMicrosoft_NETFramework_ReferenceAssemblies_net472)/build/.NETFramework/v4.7.2 + diff --git a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 5c138876..a9fdc372 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -23,3 +23,7 @@ FCE015 | FirstClassErrors.DocumentationContent| Disabled | DocumentationTitleTo FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer FCE017 | FirstClassErrors.Usage | Disabled | SensitiveDataInErrorContextAnalyzer FCE018 | FirstClassErrors.Usage | Disabled | OversizedErrorContextValueAnalyzer +FCE019 | FirstClassErrors.Usage | Warning | TryCatchesTooBroadlyAnalyzer +FCE020 | FirstClassErrors.Usage | Disabled | TryCatchesRichProtocolExceptionAnalyzer +FCE021 | FirstClassErrors.Usage | Warning | PreferNonThrowingAlternativeToTryAnalyzer +FCE022 | FirstClassErrors.Usage | Warning | TryCatchesCancellationAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 6f962534..1694770f 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -190,4 +190,44 @@ internal static class Descriptors { description: "Error context should hold small, serializable facts that log cleanly. A key typed as a byte array, a Stream or a FileInfo carries a whole file or buffer into every log line and error-catalog entry, bloating output and often smuggling sensitive data along with it. Detection is based on the key's declared value type. Opt-in.", helpLinkUri: HelpLinks.For(DiagnosticIds.OversizedErrorContextValue)); + public static readonly DiagnosticDescriptor TryCatchesTooBroadly = new( + id: DiagnosticIds.TryCatchesTooBroadly, + title: "Outcome.Try catches a too-broad exception type", + messageFormat: "Outcome.Try catches '{0}'; catch the specific exception the operation is documented to throw, not a near-root type that also swallows bugs", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Outcome.Try is meant to catch the single exception type that denotes an anticipated failure and let everything else propagate. Catching System.Exception turns unexpected bugs (a null dereference, an invalid state) into anticipated errors and defeats the purpose of Outcome. Name the specific exception instead; if a boundary genuinely must map every failure, do it explicitly rather than through Try.", + helpLinkUri: HelpLinks.For(DiagnosticIds.TryCatchesTooBroadly)); + + public static readonly DiagnosticDescriptor TryCatchesRichProtocolException = new( + id: DiagnosticIds.TryCatchesRichProtocolException, + title: "Outcome.Try catches a protocol failure that carries more than the exception", + messageFormat: "Outcome.Try catches '{0}', a protocol failure carrying status or result data beyond the exception; review whether a dedicated adapter should inspect the result and preserve that structured information", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: false, + description: "HTTP, socket and database failures are not fully described by the exception: a status code, a provider error number or a response body carries the real signal, one exception type spans several distinct failures with different transience, and a request timeout surfaces as an OperationCanceledException that Try lets through. Outcome.Try can reduce all of that to 'it threw'; the mapper can still read the status off the caught exception, but needing to is a sign the failure wants a dedicated result-inspecting adapter. Advisory and opt-in.", + helpLinkUri: HelpLinks.For(DiagnosticIds.TryCatchesRichProtocolException)); + + public static readonly DiagnosticDescriptor TryCatchesCancellation = new( + id: DiagnosticIds.TryCatchesCancellation, + title: "Outcome.Try catches a cancellation type, making the catch unreachable", + messageFormat: "Outcome.Try catches '{0}', a cancellation type; Outcome.Try always lets cancellation propagate, so this catch is unreachable and the mapper never runs", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Outcome.Try guards its catch with 'when (exception is not OperationCanceledException)' so a cancellation is never turned into an error — it always propagates. Binding TException to OperationCanceledException (or a subtype such as TaskCanceledException) therefore produces a catch that can never engage: the exception filter becomes a contradiction ('is an OperationCanceledException and is not one'), the mapper never runs, and no Outcome is produced. This is always a mistake, and it is silent (an always-false filter is not a compile error). Cancellation cannot be modelled as a Try failure: remove the cancellation handling from Try, or catch a specific non-cancellation exception.", + helpLinkUri: HelpLinks.For(DiagnosticIds.TryCatchesCancellation)); + + public static readonly DiagnosticDescriptor PreferNonThrowingAlternativeToTry = new( + id: DiagnosticIds.PreferNonThrowingAlternativeToTry, + title: "Outcome.Try wraps an operation that has a non-throwing alternative", + messageFormat: "Outcome.Try wraps '{0}'; a non-throwing '{1}' is available — consider mapping its result instead of catching", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "When a wrapped call already has a non-throwing counterpart — a 'bool TryParse(..., out T)' or 'TryCreate' with a matching drop-in signature — there is usually no exception worth catching, so Outcome.Try adds cost and hides the cheaper path. It fires wherever such a counterpart resolves, regardless of where the wrapped type is declared. The rule is framework-aware: it fires only when the counterpart actually resolves, with a compatible signature, in the compilation being analyzed, so a target framework that lacks it (older .NET Standard / .NET Framework, where MailAddress.TryCreate or Convert.TryFromBase64String do not exist) is never flagged. It is an advisory (a suggestion, not an equivalence claim): a structurally-matching TryXxx may still normalize its input, diverge on culture, or report a different set of failures than the exception you catch (int.Parse also throws on overflow, which int.TryParse folds into false), and the TryXxx form has no exception to hand your mapper — so confirm it behaves identically before rewriting, and suppress the rule (SuppressMessage / #pragma) where it does not fit. Detection is limited to a single static call or a constructor in the lambda body, whose signature the counterpart must match exactly.", + helpLinkUri: HelpLinks.For(DiagnosticIds.PreferNonThrowingAlternativeToTry)); + } diff --git a/FirstClassErrors.Analyzers/DiagnosticIds.cs b/FirstClassErrors.Analyzers/DiagnosticIds.cs index 284f83c0..19bd5a03 100644 --- a/FirstClassErrors.Analyzers/DiagnosticIds.cs +++ b/FirstClassErrors.Analyzers/DiagnosticIds.cs @@ -31,5 +31,9 @@ internal static class DiagnosticIds { public const string UnusedToExceptionResult = "FCE016"; public const string SensitiveDataInErrorContext = "FCE017"; public const string OversizedErrorContextValue = "FCE018"; + public const string TryCatchesTooBroadly = "FCE019"; + public const string TryCatchesRichProtocolException = "FCE020"; + public const string PreferNonThrowingAlternativeToTry = "FCE021"; + public const string TryCatchesCancellation = "FCE022"; } diff --git a/FirstClassErrors.Analyzers/OutcomeTryFacts.cs b/FirstClassErrors.Analyzers/OutcomeTryFacts.cs new file mode 100644 index 00000000..119499df --- /dev/null +++ b/FirstClassErrors.Analyzers/OutcomeTryFacts.cs @@ -0,0 +1,45 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// Symbol-inspection helpers shared by the Outcome.Try usage analyzers (FCE019, FCE020, FCE022). Every +/// Try overload declares the caught exception through a type parameter named TException; these +/// helpers recognize a call to FirstClassErrors.Outcome.Try<...> and surface the concrete type bound +/// to it. +/// +internal static class OutcomeTryFacts { + + public const string OutcomeMetadataName = "FirstClassErrors.Outcome"; + + private const string TryMethodName = "Try"; + private const string ExceptionTypeParameterName = "TException"; + + /// + /// Determines whether is a call to FirstClassErrors.Outcome.Try and, if so, + /// yields the concrete type bound to its TException type parameter. + /// + public static bool TryGetCaughtExceptionType( + IInvocationOperation invocation, + INamedTypeSymbol outcomeType, + out ITypeSymbol? caughtExceptionType) { + + caughtExceptionType = null; + + IMethodSymbol method = invocation.TargetMethod; + if (method.Name != TryMethodName) { return false; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, outcomeType)) { return false; } + + for (int i = 0; i < method.TypeParameters.Length; i++) { + if (method.TypeParameters[i].Name == ExceptionTypeParameterName) { + caughtExceptionType = method.TypeArguments[i]; + + return caughtExceptionType is not null; + } + } + + return false; + } + +} diff --git a/FirstClassErrors.Analyzers/PreferNonThrowingAlternativeToTryAnalyzer.cs b/FirstClassErrors.Analyzers/PreferNonThrowingAlternativeToTryAnalyzer.cs new file mode 100644 index 00000000..10d184f5 --- /dev/null +++ b/FirstClassErrors.Analyzers/PreferNonThrowingAlternativeToTryAnalyzer.cs @@ -0,0 +1,53 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE021 — reports a synchronous, value-producing Outcome.Try whose operation is a lambda that only calls a +/// single throwing member which already has a non-throwing counterpart (a bool TryParse(..., out T) or +/// TryCreate of matching shape) available for the target framework being compiled. There is nothing to catch: +/// the caller should map the counterpart's false result to an error instead. On by default as a warning +/// (advisory: suppress where the counterpart is not a true inverse of the wrapped call). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PreferNonThrowingAlternativeToTryAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.PreferNonThrowingAlternativeToTry); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName); + if (outcomeType is null) { return; } + + INamedTypeSymbol? funcOfTType = context.Compilation.GetTypeByMetadataName(TryAlternativeFacts.FuncOfTMetadataName); + if (funcOfTType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, funcOfTType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, INamedTypeSymbol funcOfTType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out _)) { return; } + if (!TryAlternativeFacts.TryGetPreferredAlternative(invocation, funcOfTType, out string throwingDisplay, out string alternativeDisplay)) { return; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.PreferNonThrowingAlternativeToTry, + invocation.Syntax.GetLocation(), + throwingDisplay, + alternativeDisplay)); + } + +} diff --git a/FirstClassErrors.Analyzers/TryAlternativeFacts.cs b/FirstClassErrors.Analyzers/TryAlternativeFacts.cs new file mode 100644 index 00000000..d41471dd --- /dev/null +++ b/FirstClassErrors.Analyzers/TryAlternativeFacts.cs @@ -0,0 +1,195 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// Detection logic for FCE021. Recognizes a synchronous, value-producing Outcome.Try whose operation is a +/// lambda that does nothing but call a single throwing member, and decides whether that member has a non-throwing +/// counterpart (Try<Name> for a method, TryParse or TryCreate for a constructor) that +/// actually resolves — with a compatible signature — in the compilation being analyzed. +/// +/// +/// +/// The rule fires wherever a matching non-throwing counterpart resolves, regardless of where the wrapped type is +/// declared. Structural matching cannot prove behavioural equivalence — a look-alike TryXxx may normalize +/// its input, apply a different culture, have side effects, or be unrelated — so the diagnostic is advisory: it +/// surfaces the candidate and leaves the judgement (and any SuppressMessage) to the developer. The +/// signature check (same parameters, matching ref kinds, a trailing out of the result type, returning +/// bool) at least guarantees the suggested call would compile. +/// +/// +/// The signature check (same parameters, out result) : bool is what makes the rule both framework-aware +/// and false-positive-resistant: the counterpart is looked up on the wrapped member's own type (and its base +/// types) through the semantic model, so it is only found when the consumer's target framework exposes it, and +/// only accepted when its shape is an exact drop-in for the throwing call. +/// +/// +internal static class TryAlternativeFacts { + + public const string FuncOfTMetadataName = "System.Func`1"; + + private const string OperationParameterName = "operation"; + private const string TryPrefix = "Try"; + + private static readonly string[] ConstructorCounterpartNames = { "TryParse", "TryCreate" }; + + /// + /// Determines whether is the synchronous value-producing Outcome.Try + /// overload wrapping a single throwing framework member that has a resolvable non-throwing counterpart, and yields + /// display names for the message. + /// + public static bool TryGetPreferredAlternative( + IInvocationOperation tryInvocation, + INamedTypeSymbol funcOfTType, + out string throwingDisplay, + out string alternativeDisplay) { + + throwingDisplay = string.Empty; + alternativeDisplay = string.Empty; + + if (!TryGetSingleThrowingCall(tryInvocation, funcOfTType, out IMethodSymbol? throwingMember, out ITypeSymbol? resultType, out bool isConstructor)) { + return false; + } + + INamedTypeSymbol containingType = throwingMember!.ContainingType; + + string[] counterpartNames = isConstructor ? ConstructorCounterpartNames : new[] { TryPrefix + throwingMember.Name }; + + foreach (string counterpartName in counterpartNames) { + if (!HasCompatibleCounterpart(containingType, counterpartName, throwingMember, resultType!)) { continue; } + + string typeName = containingType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + throwingDisplay = isConstructor ? $"new {typeName}" : $"{typeName}.{throwingMember.Name}"; + alternativeDisplay = $"{typeName}.{counterpartName}"; + + return true; + } + + return false; + } + + private static bool TryGetSingleThrowingCall( + IInvocationOperation tryInvocation, + INamedTypeSymbol funcOfTType, + out IMethodSymbol? throwingMember, + out ITypeSymbol? resultType, + out bool isConstructor) { + + throwingMember = null; + resultType = null; + isConstructor = false; + + IArgumentOperation? operationArgument = FindArgument(tryInvocation, OperationParameterName); + if (operationArgument is null) { return false; } + + // Only the synchronous value overload, whose operation is a Func. The async and void overloads + // (Func>, Action, Func) have no plain result to hand to + // an out parameter, so they are out of scope. + if (operationArgument.Parameter?.Type is not INamedTypeSymbol parameterType || + !SymbolEqualityComparer.Default.Equals(parameterType.OriginalDefinition, funcOfTType)) { + return false; + } + + if (operationArgument.Value is not IDelegateCreationOperation { Target: IAnonymousFunctionOperation lambda }) { return false; } + if (!TryGetSingleReturnedValue(lambda.Body, out IOperation? returnedValue)) { return false; } + + switch (returnedValue) { + // Static only: an instance call's receiver cannot be carried into a static TryXxx, so the rewrite would + // not be one-for-one. + case IInvocationOperation { TargetMethod: { ReturnsVoid: false, IsStatic: true } method }: + throwingMember = method; + resultType = method.ReturnType; + isConstructor = false; + + return true; + + // An object/collection initializer cannot be carried into a TryXxx, so reject any construction that has one. + case IObjectCreationOperation { Initializer: null, Constructor: { } constructor, Type: INamedTypeSymbol createdType }: + throwingMember = constructor; + resultType = createdType; + isConstructor = true; + + return true; + + default: + return false; + } + } + + // A lambda body counts only when it is exactly one returned expression: `() => X.Parse(s)` or + // `() => { return X.Parse(s); }`. Anything else (extra statements, a cast around the call, no return) is left + // alone, so the suggested rewrite stays an exact one-for-one replacement. + private static bool TryGetSingleReturnedValue(IBlockOperation body, out IOperation? returnedValue) { + returnedValue = null; + + if (body.Operations.Length != 1) { return false; } + if (body.Operations[0] is not IReturnOperation { ReturnedValue: { } value }) { return false; } + + returnedValue = value; + + return true; + } + + private static bool HasCompatibleCounterpart( + INamedTypeSymbol containingType, + string counterpartName, + IMethodSymbol throwingMember, + ITypeSymbol resultType) { + + for (INamedTypeSymbol? type = containingType; type is not null; type = type.BaseType) { + foreach (ISymbol member in type.GetMembers(counterpartName)) { + if (member is not IMethodSymbol candidate) { continue; } + if (!candidate.IsStatic || candidate.DeclaredAccessibility != Accessibility.Public) { continue; } + if (candidate.ReturnType.SpecialType != SpecialType.System_Boolean) { continue; } + + IMethodSymbol constructed; + if (throwingMember.TypeArguments.Length > 0) { + if (candidate.TypeParameters.Length != throwingMember.TypeArguments.Length) { continue; } + constructed = candidate.Construct(throwingMember.TypeArguments.ToArray()); + } else if (candidate.TypeParameters.Length != 0) { + continue; + } else { + constructed = candidate; + } + + if (SignatureIsDropInReplacement(throwingMember.Parameters, constructed.Parameters, resultType)) { + return true; + } + } + } + + return false; + } + + // The counterpart must take the throwing member's parameters unchanged — same types AND same ref kinds — then a + // single trailing `out result`, and nothing else. + private static bool SignatureIsDropInReplacement( + ImmutableArray throwingParameters, + ImmutableArray candidateParameters, + ITypeSymbol resultType) { + + if (candidateParameters.Length != throwingParameters.Length + 1) { return false; } + + for (int i = 0; i < throwingParameters.Length; i++) { + if (candidateParameters[i].RefKind == RefKind.Out) { return false; } + if (candidateParameters[i].RefKind != throwingParameters[i].RefKind) { return false; } + if (!SymbolEqualityComparer.Default.Equals(candidateParameters[i].Type, throwingParameters[i].Type)) { return false; } + } + + IParameterSymbol trailing = candidateParameters[candidateParameters.Length - 1]; + + return trailing.RefKind == RefKind.Out && SymbolEqualityComparer.Default.Equals(trailing.Type, resultType); + } + + private static IArgumentOperation? FindArgument(IInvocationOperation invocation, string parameterName) { + foreach (IArgumentOperation argument in invocation.Arguments) { + if (argument.Parameter?.Name == parameterName) { return argument; } + } + + return null; + } + +} diff --git a/FirstClassErrors.Analyzers/TryCatchesCancellationAnalyzer.cs b/FirstClassErrors.Analyzers/TryCatchesCancellationAnalyzer.cs new file mode 100644 index 00000000..390cabf9 --- /dev/null +++ b/FirstClassErrors.Analyzers/TryCatchesCancellationAnalyzer.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE022 — reports an Outcome.Try<..., TException> whose caught type is, or derives from, +/// . Try guards its catch with +/// when (exception is not OperationCanceledException), so binding TException to a cancellation type +/// makes the catch unreachable: the filter is a contradiction, the mapper never runs, and no Outcome is +/// produced. It is always a mistake and, unlike an unreachable ordinary catch, the compiler does not flag it. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TryCatchesCancellationAnalyzer : DiagnosticAnalyzer { + + private const string OperationCanceledExceptionMetadataName = "System.OperationCanceledException"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.TryCatchesCancellation); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName); + if (outcomeType is null) { return; } + + INamedTypeSymbol? cancellationType = context.Compilation.GetTypeByMetadataName(OperationCanceledExceptionMetadataName); + if (cancellationType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, cancellationType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, INamedTypeSymbol cancellationType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out ITypeSymbol? caughtType)) { return; } + if (!SymbolFacts.IsOrInheritsFrom(caughtType!, cancellationType)) { return; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.TryCatchesCancellation, + invocation.Syntax.GetLocation(), + caughtType!.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + } + +} diff --git a/FirstClassErrors.Analyzers/TryCatchesRichProtocolExceptionAnalyzer.cs b/FirstClassErrors.Analyzers/TryCatchesRichProtocolExceptionAnalyzer.cs new file mode 100644 index 00000000..9ce36735 --- /dev/null +++ b/FirstClassErrors.Analyzers/TryCatchesRichProtocolExceptionAnalyzer.cs @@ -0,0 +1,73 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE020 — reports an Outcome.Try<..., TException> whose caught type is (or derives from) a protocol +/// failure such as HttpRequestException, WebException, SocketException or DbException. +/// These failures carry status or protocol data beyond the exception, so a dedicated adapter that inspects the result +/// keeps information Try would discard. Opt-in and disabled by default. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TryCatchesRichProtocolExceptionAnalyzer : DiagnosticAnalyzer { + + private static readonly string[] ProtocolExceptionMetadataNames = { + "System.Net.Http.HttpRequestException", + "System.Net.WebException", + "System.Net.Sockets.SocketException", + "System.Data.Common.DbException", + }; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.TryCatchesRichProtocolException); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName); + if (outcomeType is null) { return; } + + ImmutableArray protocolTypes = ResolveProtocolTypes(context.Compilation); + if (protocolTypes.IsEmpty) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, protocolTypes), OperationKind.Invocation); + } + + private static ImmutableArray ResolveProtocolTypes(Compilation compilation) { + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + foreach (string metadataName in ProtocolExceptionMetadataNames) { + INamedTypeSymbol? type = compilation.GetTypeByMetadataName(metadataName); + if (type is not null) { builder.Add(type); } + } + + return builder.ToImmutable(); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, ImmutableArray protocolTypes) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out ITypeSymbol? caughtType)) { return; } + + foreach (INamedTypeSymbol protocolType in protocolTypes) { + if (!SymbolFacts.IsOrInheritsFrom(caughtType!, protocolType)) { continue; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.TryCatchesRichProtocolException, + invocation.Syntax.GetLocation(), + caughtType!.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + + return; + } + } + +} diff --git a/FirstClassErrors.Analyzers/TryCatchesTooBroadlyAnalyzer.cs b/FirstClassErrors.Analyzers/TryCatchesTooBroadlyAnalyzer.cs new file mode 100644 index 00000000..dadf45b9 --- /dev/null +++ b/FirstClassErrors.Analyzers/TryCatchesTooBroadlyAnalyzer.cs @@ -0,0 +1,52 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE019 — reports an Outcome.Try<..., TException> whose caught type is . +/// Try is meant to catch the single exception that denotes an anticipated failure; catching the near-root type +/// also swallows unexpected bugs and turns them into anticipated errors, defeating the purpose of Outcome. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TryCatchesTooBroadlyAnalyzer : DiagnosticAnalyzer { + + private const string ExceptionMetadataName = "System.Exception"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.TryCatchesTooBroadly); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName); + if (outcomeType is null) { return; } + + INamedTypeSymbol? exceptionType = context.Compilation.GetTypeByMetadataName(ExceptionMetadataName); + if (exceptionType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, exceptionType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, INamedTypeSymbol exceptionType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out ITypeSymbol? caughtType)) { return; } + if (!SymbolEqualityComparer.Default.Equals(caughtType, exceptionType)) { return; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.TryCatchesTooBroadly, + invocation.Syntax.GetLocation(), + caughtType!.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + } + +} diff --git a/FirstClassErrors.UnitTests/OutcomeTryTests.cs b/FirstClassErrors.UnitTests/OutcomeTryTests.cs new file mode 100644 index 00000000..f8b65cd4 --- /dev/null +++ b/FirstClassErrors.UnitTests/OutcomeTryTests.cs @@ -0,0 +1,598 @@ +#region Usings declarations + +using FirstClassErrors.Testing; + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.UnitTests; + +[TestSubject(typeof(Outcome))] +public sealed class OutcomeTryTests { + + #region Synchronous value-producing Try + + [Fact(DisplayName = "Try returns a success carrying the result when the operation does not throw.")] + public void TryReturnsASuccessCarryingTheResultWhenTheOperationDoesNotThrow() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = Outcome.Try(() => 42, _ => error); + + // Verify + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo(42); + } + + [Fact(DisplayName = "Try maps the caught exception to a failure carrying the mapper's error.")] + public void TryMapsTheCaughtExceptionToAFailureCarryingTheMappersError() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = Outcome.Try((Func)(() => throw new InvalidOperationException("boom")), _ => error); + + // Verify + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Try passes the thrown exception instance to the mapper.")] + public void TryPassesTheThrownExceptionInstanceToTheMapper() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + InvalidOperationException thrown = new("boom"); + Exception? received = null; + + // Exercise + Outcome.Try((Func)(() => throw thrown), exception => { + received = exception; + + return error; + }); + + // Verify + Check.That(received).IsSameReferenceAs(thrown); + } + + [Fact(DisplayName = "Try does not catch an exception of a different type.")] + public void TryDoesNotCatchAnExceptionOfADifferentType() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + Check.ThatCode(() => Outcome.Try((Func)(() => throw new InvalidOperationException("boom")), _ => error)) + .Throws() + .WithMessage("boom"); + } + + [Fact(DisplayName = "Try lets an OperationCanceledException propagate even when TException is Exception.")] + public void TryLetsAnOperationCanceledExceptionPropagateEvenWhenTExceptionIsException() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + Check.ThatCode(() => Outcome.Try((Func)(() => throw new OperationCanceledException()), _ => error)) + .Throws(); + } + + [Fact(DisplayName = "Try guards against a null operation.")] + public void TryGuardsAgainstANullOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + Check.ThatCode(() => Outcome.Try((Func)null!, _ => error)) + .Throws(); + } + + [Fact(DisplayName = "Try guards against a null onError mapper.")] + public void TryGuardsAgainstANullOnErrorMapper() { + // Exercise & verify + Check.ThatCode(() => Outcome.Try(() => 42, (Func)null!)) + .Throws(); + } + + [Fact(DisplayName = "Try throws an ArgumentNullException when the mapper maps a caught exception to a null error.")] + public void TryThrowsAnArgumentNullExceptionWhenTheMapperReturnsANullError() { + // Exercise & verify + Check.ThatCode(() => Outcome.Try((Func)(() => throw new InvalidOperationException()), _ => (Error)null!)) + .Throws(); + } + + [Fact(DisplayName = "Try surfaces a null operation result as a contract violation rather than mapping it, even when TException is broad.")] + public void TrySurfacesANullOperationResultRatherThanMappingIt() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify: Success rejects a null result, and because it runs after the catch that + // ArgumentNullException surfaces as the contract violation it is rather than being mapped through onError. + Check.ThatCode(() => Outcome.Try((Func)(() => null!), _ => error)) + .Throws(); + } + + #endregion + + #region Synchronous side-effecting Try + + [Fact(DisplayName = "Try (void) returns a success and runs the side effect when the operation does not throw.")] + public void TryVoidReturnsASuccessAndRunsTheSideEffectWhenTheOperationDoesNotThrow() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + bool ran = false; + + // Exercise + Outcome outcome = Outcome.Try(() => { ran = true; }, _ => error); + + // Verify + Check.That(ran).IsTrue(); + Check.That(outcome.IsSuccess).IsTrue(); + } + + [Fact(DisplayName = "Try (void) maps the caught exception to a failure.")] + public void TryVoidMapsTheCaughtExceptionToAFailure() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = Outcome.Try((Action)(() => throw new InvalidOperationException()), _ => error); + + // Verify + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Try (void) does not catch an exception of a different type.")] + public void TryVoidDoesNotCatchAnExceptionOfADifferentType() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + Check.ThatCode(() => Outcome.Try((Action)(() => throw new InvalidOperationException("boom")), _ => error)) + .Throws() + .WithMessage("boom"); + } + + [Fact(DisplayName = "Try (void) lets an OperationCanceledException propagate.")] + public void TryVoidLetsAnOperationCanceledExceptionPropagate() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + Check.ThatCode(() => Outcome.Try((Action)(() => throw new OperationCanceledException()), _ => error)) + .Throws(); + } + + [Fact(DisplayName = "Try (void) guards against a null operation.")] + public void TryVoidGuardsAgainstANullOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + Check.ThatCode(() => Outcome.Try((Action)null!, _ => error)) + .Throws(); + } + + [Fact(DisplayName = "Try (void) guards against a null onError mapper.")] + public void TryVoidGuardsAgainstANullOnErrorMapper() { + // Exercise & verify + Check.ThatCode(() => Outcome.Try(() => { }, (Func)null!)) + .Throws(); + } + + #endregion + + #region Asynchronous value-producing Try + + [Fact(DisplayName = "Awaiting the async Try returns a success carrying the result when the operation does not throw.")] + public async Task AwaitingTheAsyncTryReturnsASuccessCarryingTheResultWhenTheOperationDoesNotThrow() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = await Outcome.Try( + _ => Task.FromResult(42), _ => error, TestContext.Current.CancellationToken); + + // Verify + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo(42); + } + + [Fact(DisplayName = "Awaiting the async Try maps a caught exception to a failure.")] + public async Task AwaitingTheAsyncTryMapsACaughtExceptionToAFailure() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = await Outcome.Try( + async _ => { + await Task.Yield(); + + throw new InvalidOperationException("boom"); + }, + _ => error, + TestContext.Current.CancellationToken); + + // Verify + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Awaiting the async Try does not catch an exception of a different type.")] + public async Task AwaitingTheAsyncTryDoesNotCatchAnExceptionOfADifferentType() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try( + async _ => { + await Task.Yield(); + + throw new InvalidOperationException("boom"); + }, + _ => error, + TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "Awaiting the async Try lets a cancellation propagate rather than mapping it to a failure.")] + public async Task AwaitingTheAsyncTryLetsACancellationPropagateRatherThanMappingItToAFailure() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try( + ct => throw new OperationCanceledException(ct), + _ => error, + cts.Token)); + } + + [Fact(DisplayName = "Awaiting the async Try passes the cancellation token to the operation.")] + public async Task AwaitingTheAsyncTryPassesTheCancellationTokenToTheOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + using CancellationTokenSource cts = new(); + CancellationToken received = default; + + // Exercise + await Outcome.Try( + ct => { + received = ct; + + return Task.FromResult(0); + }, + _ => error, + cts.Token); + + // Verify + Check.That(received).IsEqualTo(cts.Token); + } + + [Fact(DisplayName = "Awaiting the async Try surfaces a null task returned by the operation.")] + public async Task AwaitingTheAsyncTrySurfacesANullTaskReturnedByTheOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try((Func>)(_ => null!), _ => error, TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "Awaiting the async Try surfaces a null task even when TException is broad, rather than mapping it.")] + public async Task AwaitingTheAsyncTrySurfacesANullTaskEvenWhenTExceptionIsBroad() { + // A null task is a contract violation, not an anticipated failure: the guard sits outside the mapping region, + // so it surfaces as an InvalidOperationException even under a broad Exception catch, mirroring the null-result + // contract. A specific TException surfaces it too (see the test above). + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try( + (Func>)(_ => null!), _ => error, TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "Awaiting the async Try surfaces a null operation result as a contract violation rather than mapping it, even when TException is broad.")] + public async Task AwaitingTheAsyncTrySurfacesANullOperationResultRatherThanMappingIt() { + // Distinct from the null-task case above: here the operation returns a non-null task that resolves to a null + // value. Success rejects it after the catch, so the ArgumentNullException surfaces even though TException is + // broad enough to have mapped it. + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try(_ => Task.FromResult(null!), _ => error, TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "Awaiting the async Try guards against a null operation.")] + public async Task AwaitingTheAsyncTryGuardsAgainstANullOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try((Func>)null!, _ => error, TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "Awaiting the async Try guards against a null onError mapper.")] + public async Task AwaitingTheAsyncTryGuardsAgainstANullOnErrorMapper() { + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try(_ => Task.FromResult(0), (Func)null!, TestContext.Current.CancellationToken)); + } + + #endregion + + #region Asynchronous side-effecting Try + + [Fact(DisplayName = "Awaiting the async Try (void) returns a success and runs the side effect when the operation does not throw.")] + public async Task AwaitingTheAsyncTryVoidReturnsASuccessAndRunsTheSideEffectWhenTheOperationDoesNotThrow() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + bool ran = false; + + // Exercise + Outcome outcome = await Outcome.Try( + async _ => { + await Task.Yield(); + + ran = true; + }, + _ => error, + TestContext.Current.CancellationToken); + + // Verify + Check.That(ran).IsTrue(); + Check.That(outcome.IsSuccess).IsTrue(); + } + + [Fact(DisplayName = "Awaiting the async Try (void) maps a caught exception to a failure.")] + public async Task AwaitingTheAsyncTryVoidMapsACaughtExceptionToAFailure() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = await Outcome.Try( + async _ => { + await Task.Yield(); + + throw new InvalidOperationException(); + }, + _ => error, + TestContext.Current.CancellationToken); + + // Verify + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Awaiting the async Try (void) lets a cancellation propagate.")] + public async Task AwaitingTheAsyncTryVoidLetsACancellationPropagate() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try( + ct => throw new OperationCanceledException(ct), + _ => error, + cts.Token)); + } + + [Fact(DisplayName = "Awaiting the async Try (void) guards against a null operation.")] + public async Task AwaitingTheAsyncTryVoidGuardsAgainstANullOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try((Func)null!, _ => error, TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "Awaiting the async Try (void) does not catch an exception of a different type.")] + public async Task AwaitingTheAsyncTryVoidDoesNotCatchAnExceptionOfADifferentType() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try( + async _ => { + await Task.Yield(); + + throw new InvalidOperationException("boom"); + }, + _ => error, + TestContext.Current.CancellationToken)); + } + + [Fact(DisplayName = "Awaiting the async Try (void) guards against a null onError mapper.")] + public async Task AwaitingTheAsyncTryVoidGuardsAgainstANullOnErrorMapper() { + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try( + (CancellationToken _) => Task.CompletedTask, (Func)null!, TestContext.Current.CancellationToken)); + } + + #endregion + + #region Asynchronous value-producing Try (no cancellation token) + + [Fact(DisplayName = "Awaiting the token-less async Try returns a success carrying the result when the operation does not throw.")] + public async Task AwaitingTheTokenlessAsyncTryReturnsASuccessCarryingTheResult() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise: an async lambda with no token binds to the Func> overload. + Outcome outcome = await Outcome.Try( + async () => { + await Task.Yield(); + + return 42; + }, + _ => error); + + // Verify + Check.That(outcome.IsSuccess).IsTrue(); + Check.That(outcome.GetResultOrThrow()).IsEqualTo(42); + } + + [Fact(DisplayName = "Awaiting the token-less async Try maps a throwing async lambda to a failure.")] + public async Task AwaitingTheTokenlessAsyncTryMapsAThrowingAsyncLambda() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = await Outcome.Try( + async () => { + await Task.Yield(); + + throw new InvalidOperationException("boom"); + }, + _ => error); + + // Verify + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Awaiting the token-less async Try surfaces a null operation result rather than mapping it.")] + public async Task AwaitingTheTokenlessAsyncTrySurfacesANullOperationResult() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try(() => Task.FromResult(null!), _ => error)); + } + + [Fact(DisplayName = "Awaiting the token-less async Try guards against a null operation.")] + public async Task AwaitingTheTokenlessAsyncTryGuardsAgainstANullOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try((Func>)null!, _ => error)); + } + + [Fact(DisplayName = "Awaiting the token-less async Try guards against a null onError mapper.")] + public async Task AwaitingTheTokenlessAsyncTryGuardsAgainstANullOnErrorMapper() { + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try(() => Task.FromResult(0), (Func)null!)); + } + + #endregion + + #region Asynchronous side-effecting Try (no cancellation token) + + [Fact(DisplayName = "Awaiting the token-less async Try (void) returns a success and runs the side effect when the operation does not throw.")] + public async Task AwaitingTheTokenlessAsyncTryVoidReturnsASuccessAndRunsTheSideEffect() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + bool ran = false; + + // Exercise + Outcome outcome = await Outcome.Try( + async () => { + await Task.Yield(); + + ran = true; + }, + _ => error); + + // Verify + Check.That(ran).IsTrue(); + Check.That(outcome.IsSuccess).IsTrue(); + } + + [Fact(DisplayName = "Awaiting the token-less async Try (void) maps a throwing async lambda instead of letting it escape as async void.")] + public async Task AwaitingTheTokenlessAsyncTryVoidMapsAThrowingAsyncLambda() { + // Regression guard for the async-void footgun: a parameterless async lambda now binds to the Func + // overload, so its post-await exception is awaited and mapped rather than raised out-of-band. + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = await Outcome.Try( + async () => { + await Task.Yield(); + + throw new InvalidOperationException("boom"); + }, + _ => error); + + // Verify + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Awaiting the token-less async Try (void) maps a fire-and-forget task that faults instead of dropping it.")] + public async Task AwaitingTheTokenlessAsyncTryVoidMapsAFireAndForgetTask() { + // Regression guard for the non-async fire-and-forget case: () => ReturnsTask() binds to the Func overload + // and is awaited, so a faulting task is mapped rather than silently dropped as it would be on the Action overload. + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise + Outcome outcome = await Outcome.Try(() => FaultingTaskAsync(), _ => error); + + // Verify + Check.That(outcome.IsFailure).IsTrue(); + Check.That(outcome.Error).IsSameReferenceAs(error); + } + + [Fact(DisplayName = "Awaiting the token-less async Try (void) does not catch an exception of a different type.")] + public async Task AwaitingTheTokenlessAsyncTryVoidDoesNotCatchADifferentType() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try( + async () => { + await Task.Yield(); + + throw new InvalidOperationException("boom"); + }, + _ => error)); + } + + [Fact(DisplayName = "Awaiting the token-less async Try (void) guards against a null operation.")] + public async Task AwaitingTheTokenlessAsyncTryVoidGuardsAgainstANullOperation() { + // Setup + DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()); + + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try((Func)null!, _ => error)); + } + + [Fact(DisplayName = "Awaiting the token-less async Try (void) guards against a null onError mapper.")] + public async Task AwaitingTheTokenlessAsyncTryVoidGuardsAgainstANullOnErrorMapper() { + // Exercise & verify + await Assert.ThrowsAsync( + () => Outcome.Try(() => Task.CompletedTask, (Func)null!)); + } + + private static Task FaultingTaskAsync() { + return Task.FromException(new InvalidOperationException("boom")); + } + + #endregion + +} diff --git a/FirstClassErrors/Outcome.cs b/FirstClassErrors/Outcome.cs index 11a68ff8..da22dea2 100644 --- a/FirstClassErrors/Outcome.cs +++ b/FirstClassErrors/Outcome.cs @@ -41,6 +41,315 @@ public static Outcome Failure(Error error) { return new Outcome(error); } + /// + /// Runs a value-producing operation that may throw, catching a single exception type and turning it into a failed + /// through the supplied mapper. + /// + /// The type of the value produced on success. + /// The single exception type to catch and map; any other exception propagates. + /// The operation to run. + /// + /// Maps a caught to the that describes the failure. It is + /// mandatory: the library never converts an exception to an error automatically, because an automatic conversion + /// would yield errors without a stable — the very thing the FCE005 analyzer discourages. + /// This mapper is the natural place to extract only what is safe from the exception rather than leaking its raw + /// message (see the FCE017/FCE018 analyzers on sensitive and oversized context data). + /// + /// + /// A successful carrying the operation's result, or a failed one carrying the mapped + /// error when threw a . + /// + /// + /// Thrown if or is null; if + /// returns a null result (which rejects as + /// a contract violation rather than mapping it); or if a caught exception is mapped by + /// to a null error (the mapped value flows through , which rejects + /// null). + /// + /// + /// + /// Try brings throwing code into the outcome flow — the inverse of + /// and , which leave it. It is a + /// narrow tool: when the operation already has a non-throwing counterpart (a bool TryParse(..., out T) + /// or TryCreate), prefer that and map its false result — there is no exception to catch. Reach + /// for Try only for a throwing primitive that has no such variant available, a case a + /// .NET Standard 2.0 consumer meets more often than a modern one. The FCE019 and FCE020 analyzers flag the + /// common misuses. + /// + /// + /// Only is caught. Any other exception propagates unchanged: an + /// models an anticipated failure, not an unexpected runtime crash, so an + /// exception you did not name is never silently turned into a value. + /// + /// + /// always propagates, even when it is assignable to + /// (for instance when is + /// ): a cancellation is not a failure to capture as an error. Binding + /// to (or its + /// TaskCanceledException subtype) is therefore pointless — the catch is unreachable and + /// never runs; do not model cancellation as a Try failure. + /// + /// + /// Try is intended for operations where the thrown exception is the whole failure signal. It is + /// deliberately not meant for protocols whose failures are carried by a status or result code rather than + /// by an exception (HTTP responses, database provider codes); those deserve a dedicated adapter that inspects + /// the result instead of catching a throw. + /// + /// + public static Outcome Try(Func operation, Func onError) + where T : notnull + where TException : Exception { + if (operation is null) { throw new ArgumentNullException(nameof(operation)); } + if (onError is null) { throw new ArgumentNullException(nameof(onError)); } + + T result; + try { + result = operation(); + } catch (TException exception) when (exception is not OperationCanceledException) { + return Outcome.Failure(onError(exception)); + } + + // Success runs after the catch on purpose: it rejects a null result, and a null result is a contract + // violation of the operation, not an anticipated failure. Calling it here lets that ArgumentNullException + // surface instead of being mapped by onError when TException is broad enough to catch it. + return Outcome.Success(result); + } + + /// + /// Runs a side-effecting operation that may throw, catching a single exception type and turning it into a failed + /// through the supplied mapper. + /// + /// The single exception type to catch and map; any other exception propagates. + /// The operation to run. + /// + /// Maps a caught to the that describes the failure. See the + /// value-returning overload for the doctrine on why this mapper is mandatory. + /// + /// + /// when completed, or a failed carrying + /// the mapped error when it threw a . + /// + /// + /// Thrown if or is null, or if a caught exception + /// is mapped by to a null error. + /// + /// + /// Behaves like the value-returning overload: only is caught, and + /// always propagates rather than being captured as an error. + /// + public static Outcome Try(Action operation, Func onError) + where TException : Exception { + if (operation is null) { throw new ArgumentNullException(nameof(operation)); } + if (onError is null) { throw new ArgumentNullException(nameof(onError)); } + + try { + operation(); + + return Success; + } catch (TException exception) when (exception is not OperationCanceledException) { + return Failure(onError(exception)); + } + } + + /// + /// Runs an asynchronous value-producing operation that may throw, catching a single exception type and turning it + /// into a failed through the supplied mapper. + /// + /// The type of the value produced on success. + /// The single exception type to catch and map; any other exception propagates. + /// The asynchronous operation to run. + /// + /// Maps a caught to the that describes the failure. See the + /// synchronous value-returning overload for the doctrine on why this mapper is mandatory. + /// + /// A token to observe for cancellation requests. + /// + /// A resolving to a successful carrying the operation's + /// result, or to a failed one carrying the mapped error when threw a + /// . + /// + /// + /// Thrown if or is null; if + /// resolves to a null result (which rejects + /// as a contract violation rather than mapping it); or if a caught exception is mapped by + /// to a null error. + /// + /// + /// Thrown if returns a null task — a contract violation that surfaces rather + /// than being mapped, even when is broad enough to catch it. + /// + /// + /// Behaves like the synchronous value-returning overload: only is caught, and + /// always propagates. A cancellation raised through + /// therefore surfaces to the awaiter rather than becoming a failed outcome. + /// The operation signals failure through the returned ; a null task, like a + /// null result, is a contract violation and always surfaces rather than being mapped. + /// + public static async Task> Try(Func> operation, + Func onError, + CancellationToken cancellationToken = default) + where T : notnull + where TException : Exception { + if (operation is null) { throw new ArgumentNullException(nameof(operation)); } + if (onError is null) { throw new ArgumentNullException(nameof(onError)); } + + Task task; + try { + task = operation(cancellationToken); + } catch (TException exception) when (exception is not OperationCanceledException) { + return Outcome.Failure(onError(exception)); + } + + // A null task and a null result are both contract violations of the operation, not anticipated failures, so + // both surface rather than being mapped: the null-task guard and Success both sit outside the region that maps + // the operation's exceptions. Invoking the operation stays inside the try, so a synchronous throw is still caught. + Task awaited = AsyncCallbackGuard.EnsureTask(task); + + T result; + try { + result = await awaited.ConfigureAwait(false); + } catch (TException exception) when (exception is not OperationCanceledException) { + return Outcome.Failure(onError(exception)); + } + + return Outcome.Success(result); + } + + /// + /// Runs an asynchronous side-effecting operation that may throw, catching a single exception type and turning it + /// into a failed through the supplied mapper. + /// + /// The single exception type to catch and map; any other exception propagates. + /// The asynchronous operation to run. + /// + /// Maps a caught to the that describes the failure. See the + /// synchronous value-returning overload for the doctrine on why this mapper is mandatory. + /// + /// A token to observe for cancellation requests. + /// + /// A resolving to when completed, + /// or to a failed carrying the mapped error when it threw a + /// . + /// + /// + /// Thrown if or is null, or if a caught exception + /// is mapped by to a null error. + /// + /// + /// Thrown if returns a null task — a contract violation that surfaces rather + /// than being mapped, even when is broad enough to catch it. + /// + /// + /// Behaves like the synchronous value-returning overload: only is caught, and + /// always propagates rather than being captured as an error. A + /// null task is a contract violation and always surfaces rather than being mapped. + /// + public static async Task Try(Func operation, + Func onError, + CancellationToken cancellationToken = default) + where TException : Exception { + if (operation is null) { throw new ArgumentNullException(nameof(operation)); } + if (onError is null) { throw new ArgumentNullException(nameof(onError)); } + + Task task; + try { + task = operation(cancellationToken); + } catch (TException exception) when (exception is not OperationCanceledException) { + return Failure(onError(exception)); + } + + // A null task is a contract violation, not an anticipated failure, so it surfaces rather than being mapped: the + // guard sits outside the region that maps the operation's exceptions. Invoking the operation stays inside the + // try, so a synchronous throw is still caught. + Task awaited = AsyncCallbackGuard.EnsureTask(task); + + try { + await awaited.ConfigureAwait(false); + } catch (TException exception) when (exception is not OperationCanceledException) { + return Failure(onError(exception)); + } + + return Success; + } + + /// + /// Runs an asynchronous value-producing operation that may throw, catching a single exception type and turning it + /// into a failed through the supplied mapper. This token-less overload exists so an + /// async lambda that observes no cancellation token binds here — as a -returning + /// delegate — instead of to a synchronous overload where an async lambda could bind as async void and let + /// its post-await exception escape unobserved. + /// + /// The type of the value produced on success. + /// The single exception type to catch and map; any other exception propagates. + /// The asynchronous operation to run. + /// + /// Maps a caught to the that describes the failure. See the + /// synchronous value-returning overload for the doctrine on why this mapper is mandatory. + /// + /// + /// A resolving to a successful carrying the operation's + /// result, or to a failed one carrying the mapped error when threw a + /// . + /// + /// + /// Thrown if or is null; if + /// resolves to a null result; or if a caught exception is mapped by + /// to a null error. + /// + /// + /// Thrown if returns a null task — a contract violation that surfaces rather + /// than being mapped. + /// + /// + /// Equivalent to the cancellation-aware overload invoked with a default . + /// + public static async Task> Try(Func> operation, Func onError) + where T : notnull + where TException : Exception { + if (operation is null) { throw new ArgumentNullException(nameof(operation)); } + if (onError is null) { throw new ArgumentNullException(nameof(onError)); } + + return await Try(_ => operation(), onError).ConfigureAwait(false); + } + + /// + /// Runs an asynchronous side-effecting operation that may throw, catching a single exception type and turning it + /// into a failed through the supplied mapper. This token-less overload exists so an + /// async lambda that observes no cancellation token binds here — as a -returning + /// delegate — instead of to the synchronous overload, where it would bind as async + /// void and let its post-await exception escape unobserved. It likewise awaits a fire-and-forget + /// () => ReturnsTask() that the overload would silently drop. + /// + /// The single exception type to catch and map; any other exception propagates. + /// The asynchronous operation to run. + /// + /// Maps a caught to the that describes the failure. See the + /// synchronous value-returning overload for the doctrine on why this mapper is mandatory. + /// + /// + /// A resolving to when completed, + /// or to a failed carrying the mapped error when it threw a + /// . + /// + /// + /// Thrown if or is null, or if a caught exception + /// is mapped by to a null error. + /// + /// + /// Thrown if returns a null task — a contract violation that surfaces rather + /// than being mapped. + /// + /// + /// Equivalent to the cancellation-aware overload invoked with a default . + /// + public static async Task Try(Func operation, Func onError) + where TException : Exception { + if (operation is null) { throw new ArgumentNullException(nameof(operation)); } + if (onError is null) { throw new ArgumentNullException(nameof(onError)); } + + return await Try(_ => operation(), onError).ConfigureAwait(false); + } + #endregion #region Constructors declarations diff --git a/doc/handwritten/for-maintainers/adr/0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.fr.md b/doc/handwritten/for-maintainers/adr/0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.fr.md new file mode 100644 index 00000000..14e926de --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.fr.md @@ -0,0 +1,197 @@ +# ADR-0028 | Faire entrer le code levant dans les outcomes via un Try encadré + +🌍 🇬🇧 [English](0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-21 +**Décideurs :** Reefact + +## Contexte + +FirstClassErrors existe pour rendre explicite le chemin d'échec d'une opération : +une erreur est une valeur qu'un appelant retourne et inspecte +(`Outcome`/`Outcome`), pas une exception qui voyage invisiblement. La +bibliothèque fournit déjà les sorties du flux Outcome vers les exceptions +(`GetResultOrThrow()`, `ThrowIfFailure()`, `Error.ToException()`) ; elle n'avait +aucune entrée sanctionnée dans l'autre sens. + +Or le code réel doit bien entrer dans le flux Outcome depuis des sources +levantes. Beaucoup de primitives ne signalent l'échec qu'en levant et n'exposent +aucune contrepartie non-levante sur les frameworks supportés : la bibliothèque a +pour plancher .NET Standard 2.0 / .NET Framework 4.7.2 (ADR-0022), où des formes +comme `MailAddress.TryCreate` (.NET 5+) ou `Convert.TryFromBase64String` +(.NET Core 2.1+) n'existent pas, et les bibliothèques tierces livrent +fréquemment un `Parse`/`Decode` levant sans aucun `TryXxx`. Atteindre le flux +Outcome depuis un tel appel imposait un `try`/`catch` écrit à la main qui +construit un `Outcome` — répété à chaque site d'appel. + +Ce pont écrit à la main a des modes de défaillance récurrents et silencieux : + +* Attraper `System.Exception` requalifie des bugs inattendus (un déréférencement + null, un état invalide) en erreurs *anticipées* — précisément ce qu'un + `Outcome` est documenté pour **ne pas** représenter. +* Attraper une exception de protocole riche (HTTP, socket, base de données) + écrase plusieurs échecs distincts — chacun porteur d'une donnée de statut que + l'appelant détient déjà — en un seul « ça a levé ». +* Envelopper un appel qui, lui, *possède* une contrepartie non-levante sur le + framework cible fait payer un `try`/`catch` là où une vérification de résultat + suffirait. +* Lier le type attrapé à un type d'annulation produit un catch qui ne peut + jamais s'exécuter, car l'annulation est du contrôle de flux coopératif, pas un + résultat d'échec ; le compilateur ne le signale pas. + +La bibliothèque traite par ailleurs une erreur sans code stable comme une odeur +(ses analyzers de codes d'erreur), et un message d'exception brut comme une +donnée à curer avant qu'elle n'entre dans une erreur. Une conversion +automatique exception-vers-erreur violerait les deux. + +Enfin, l'ADR-0005 a réservé le nom de fabrique *simple* à la variante retournant +un Outcome et retiré le préfixe `Try` des fabriques (`TryXxx` → `Xxx`), car un +nom `TryXxx` emprunte la forme BCL `bool TryXxx(..., out T)` sans l'honorer. + +## Décision + +FirstClassErrors fournit `Outcome.Try` comme le seul pont sanctionné d'une +opération levante vers un `Outcome` — attrapant un unique type d'exception nommé +par l'appelant, exigeant un mapper d'erreur explicite, et laissant toujours +l'annulation se propager — et maintient ce pont délibérément étroit en signalant +son mésusage par des analyzers d'usage plutôt qu'en élargissant l'API. + +## Justification + +* **Une primitive remplace un motif répété et fragile.** Le pont + `try`/`catch`-vers-`Outcome` écrit à la main se répète à chaque frontière et y + reproduit les mêmes fautes. Le replier dans un seul appel fait de la forme + correcte la forme par défaut et efface le boilerplate. +* **Le mapper est obligatoire parce que les erreurs doivent rester + first-class.** Une conversion automatique exception-vers-erreur produirait des + erreurs sans code stable et laisserait fuiter des messages d'exception bruts — + exactement ce que les conventions de code et de contexte d'erreur de la + bibliothèque découragent. Forcer l'appelant à mapper est ce qui garde l'erreur + produite curée et diagnosticable. +* **Un seul type attrapé garde l'Outcome honnête.** Un `Outcome` modélise un + échec anticipé ; attraper largement transformerait des bugs en erreurs + anticipées. Nommer un type d'exception est ce qui sépare l'échec que + l'opération est censée produire du crash qu'elle n'est pas censée produire. +* **L'annulation doit se propager.** L'annulation coopérative est une demande + d'arrêt, pas un échec à capturer ; la laisser passer est ce qui empêche `Try` + d'avaler l'annulation d'un appelant. +* **Ce sont les analyzers, pas une surface de type plus étroite, qui tracent la + frontière.** Les usages légitimes (primitives levantes-seulement sans + contrepartie sur le framework cible, API tierces levantes) et les mésusages + partagent la *même* signature et ne diffèrent que par un contexte que le + compilateur ne voit pas. Retirer de la capacité bloquerait les cas valides + pour lesquels la primitive existe ; un diagnostic de compilation, réglable, + marque le mésusage tout en laissant la capacité intacte. Chaque garde nomme un + danger précis issu du Contexte — catch trop large, résultat de protocole + jeté, alternative non-levante disponible, catch d'annulation mort — et le + consommateur peut le monter ou le supprimer. L'ADR-0005 notait qu'une + convention laissée à la seule revue finit par se réintroduire ; ici la + frontière est outillée dès le départ. +* **`Try` est un nom cohérent ici, et ne rouvre pas l'ADR-0005.** L'ADR-0005 + gouverne les noms de *fabrique*, où un préfixe `TryXxx` promettait faussement + la forme BCL `bool`+`out`. `Outcome.Try` n'est pas une fabrique ni un préfixe + `TryXxx` : c'est une opération d'ordre supérieur qui prend le travail comme un + délégué et retourne un `Outcome`. L'argument délégué rend sa forme + non-équivoque, donc elle n'emprunte aucune fausse promesse ; les deux + décisions sont orthogonales. + +## Alternatives envisagées + +### Aucune primitive — laisser les appelants écrire leur propre try/catch + +Envisagée parce qu'elle n'ajoute aucune surface publique et n'impose aucune +opinion. + +Rejetée parce qu'elle reproduit chaque mode de défaillance ci-dessus à chaque +site d'appel sans rien pour les attraper, et enterre le chemin d'échec dans une +plomberie impérative que la bibliothèque existe pour faire ressortir. L'absence +de pont sanctionné est précisément ce qui a laissé les fautes se répéter. + +### Un builder fluent multi-catch (`Try(...).Catch(...).Catch(...)`) + +Envisagé parce que le chaînage se lit bien quand plusieurs types d'exception se +mappent vers des erreurs différentes. + +Rejeté parce qu'il invite activement à attraper de nombreux types — +réintroduisant le danger du catch trop large sous forme d'affordance d'API — +complique l'endroit où l'annulation est gérée, et sert un cas que la plupart des +ponts n'ont pas : la majorité honnête anticipe exactement un type d'exception. +La primitive étroite mono-type est l'outil des 90 % ; les frontières réellement +multi-exceptions sont mieux servies par un adaptateur explicite. + +### Conversion automatique exception-vers-erreur (sans mapper obligatoire) + +Envisagée pour l'ergonomie — l'appelant n'aurait pas à écrire de mapper. + +Rejetée parce que l'erreur produite n'aurait aucun code stable et porterait un +message d'exception brut, les deux choses que les conventions de code et de +contexte d'erreur existent pour empêcher. La petite cérémonie d'un mapper +obligatoire est le prix d'une erreur first-class. + +### Encadrer la frontière en réduisant la surface de type plutôt que par des analyzers + +Envisagée parce que rendre le mésusage impossible est plus fort que le signaler. + +Rejetée parce que les appels légitimes et illégitimes partagent une seule +signature et ne diffèrent que par un contexte invisible au système de types ; +tout rétrécissement qui bloquerait le mésusage bloquerait aussi les cas anciens +frameworks et tiers pour lesquels la primitive existe. Le jugement appartient au +site d'usage, et c'est là qu'un analyzer parle. + +## Conséquences + +### Positives + +* Il existe une façon unique, évidente et sûre d'entrer dans le flux Outcome + depuis du code levant, et sa forme par défaut est la bonne ; le boilerplate + récurrent se réduit à un seul appel. +* Les erreurs produites par `Try` gardent un code mappé et stable et un message + curé, donc restent first-class. +* Les quatre modes de mésusage sont attrapés à la compilation et réglables par + consommateur, donc la garde informe sans bloquer l'usage légitime. +* La sémantique d'annulation est préservée sans que l'appelant ait à y penser. + +### Négatives + +* Nouvelle surface publique — formes à valeur et à effet de bord, chacune + synchrone et asynchrone — à maintenir et documenter sur le plancher + netstandard2.0 / net472. +* Quatre analyzers d'usage (FCE019–FCE022) avec leurs pages de règle bilingues + et leurs tests, à garder cohérents avec le comportement de la primitive. +* Le mapper obligatoire ajoute une petite cérémonie inévitable à chaque appel. + +### Risques + +* Les analyzers sont indicatifs et supprimables, donc un mésusage déterminé peut + quand même être livré : la garde réduit l'erreur, elle ne l'élimine pas. + Mitigation — les deux gardes à défaut prouvé (catch trop large, catch + d'annulation mort) sont activées par défaut en warning. +* Si le type attrapé ou le comportement d'annulation de `Try` change un jour, les + quatre analyzers et leur documentation doivent bouger en même temps ou ils + induiront en erreur. Mitigation — le comportement est verrouillé par des tests. +* Un lecteur peut percevoir le nom `Try` comme en conflit avec l'ADR-0005. + Mitigation — cet ADR consigne pourquoi les deux sont orthogonaux. + +## Actions de suivi + +* Garder le README/guide EN et la traduction française synchronisés pour la + guidance `Try` et les pages de règle FCE019–FCE022. +* Garder les analyzers FCE019–FCE022 et leurs tests alignés sur le comportement + documenté de la primitive à chaque fois que l'un ou l'autre change. + +## Références + +* ADR-0005 — réserver le nom de fabrique simple à la variante retournant un + Outcome (nommage ; orthogonal — explique pourquoi `Try` ici ne soulève aucun + conflit). +* ADR-0022 — plancher de la bibliothèque à .NET Framework 4.7.2 (pourquoi les + primitives levantes-seulement sans `TryXxx` sont un cas réel et supporté). +* ADR-0003 — unifier le mapping de valeur d'Outcome sous Then (contexte de l'API + Outcome). +* [Guide Outcome](../../for-users/OutcomeGuide.fr.md) — explication, côté + utilisateur, de l'entrée et de la sortie du flux Outcome. +* Pages de règle des analyzers [FCE019](../../for-users/analyzers/FCE019.fr.md), + [FCE020](../../for-users/analyzers/FCE020.fr.md), + [FCE021](../../for-users/analyzers/FCE021.fr.md), + [FCE022](../../for-users/analyzers/FCE022.fr.md). diff --git a/doc/handwritten/for-maintainers/adr/0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.md b/doc/handwritten/for-maintainers/adr/0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.md new file mode 100644 index 00000000..d6a21da7 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.md @@ -0,0 +1,189 @@ +# ADR-0028 | Bridge throwing code into outcomes through a guarded Try + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.fr.md) + +**Status:** Accepted +**Date:** 2026-07-21 +**Decision Makers:** Reefact + +## Context + +FirstClassErrors exists to make the failure path of an operation explicit: an +error is a value a caller returns and inspects (`Outcome`/`Outcome`), not an +exception that travels invisibly. The library already provides the exits from +the outcome flow back into exceptions (`GetResultOrThrow()`, `ThrowIfFailure()`, +`Error.ToException()`); it had no sanctioned entrance in the other direction. + +Real code must nonetheless enter the outcome flow from throwing sources. Many +primitives signal failure only by throwing and expose no non-throwing +counterpart on the frameworks this library supports: the library floors at +.NET Standard 2.0 / .NET Framework 4.7.2 (ADR-0022), where forms such as +`MailAddress.TryCreate` (.NET 5+) or `Convert.TryFromBase64String` +(.NET Core 2.1+) do not exist, and third-party libraries frequently ship a +throwing `Parse`/`Decode` with no `TryXxx` at all. Reaching the outcome flow +from such a call meant a hand-written `try`/`catch` that constructs an +`Outcome` — repeated at every call site. + +That hand-written bridge has recurring, silent failure modes: + +* Catching `System.Exception` relabels unexpected bugs (a null dereference, an + invalid state) as *anticipated* errors — the one thing an `Outcome` is + documented not to represent. +* Catching a rich protocol exception (HTTP, socket, database) collapses several + distinct failures — each carrying status data the caller already holds — into + a single "it threw". +* Wrapping a call that *does* have a non-throwing counterpart on the target + framework pays for a `try`/`catch` where a result check would do. +* Binding the caught type to a cancellation type produces a catch that can never + run, because cancellation is cooperative control flow, not a failure result; + the compiler does not flag this. + +The library also treats an error without a stable code as a smell (its error-code +analyzers), and treats a raw exception message as data that must be curated +before it enters an error. An automatic exception-to-error conversion would +violate both. + +Finally, ADR-0005 reserved the *plain* factory name for the Outcome-returning +variant and removed the `Try` prefix from factories (`TryXxx` → `Xxx`), because +a `TryXxx` name borrows the BCL `bool TryXxx(..., out T)` shape without honouring +it. + +## Decision + +FirstClassErrors provides `Outcome.Try` as the one sanctioned bridge from a +throwing operation to an `Outcome` — catching a single caller-named exception +type, requiring an explicit error mapper, and always letting cancellation +propagate — and keeps that bridge deliberately narrow by flagging its misuse +with usage analyzers rather than by broadening the API. + +## Rationale + +* **One primitive replaces a repeated, error-prone pattern.** The hand-written + `try`/`catch`-to-`Outcome` bridge recurs at every boundary and reproduces the + same mistakes. Folding it into a single call makes the correct shape the + default shape and the boilerplate disappear. +* **The mapper is mandatory because errors must stay first-class.** An automatic + exception-to-error conversion would yield errors without a stable code and + would leak raw exception messages — exactly what the library's error-code and + error-context conventions discourage. Forcing the caller to map is what keeps + the produced error curated and diagnosable. +* **A single caught type keeps the Outcome honest.** An `Outcome` models an + anticipated failure; catching broadly would turn bugs into anticipated errors. + Naming one exception type is what separates the failure the operation is + expected to produce from the crash it is not. +* **Cancellation must propagate.** Cooperative cancellation is a request to stop, + not a failure to capture; letting it through is what stops `Try` from + swallowing a caller's cancellation. +* **Analyzers, not a narrower type surface, draw the boundary.** The legitimate + uses (throw-only primitives with no counterpart on the target framework, + third-party throwing APIs) and the misuses share the *same* signature and + differ only by context the compiler cannot see. Removing capability would + block the valid cases the primitive exists for; a build-time, tunable + diagnostic marks the misuse while leaving the capability intact. Each guard + names a specific hazard from the Context — over-broad catch, a discarded + protocol result, an available non-throwing alternative, a dead cancellation + catch — and the consumer can escalate or suppress it. ADR-0005 noted that a + convention left to review alone slips back in; here the boundary is enforced by + tooling from the start. +* **`Try` is a coherent name here, and does not reopen ADR-0005.** ADR-0005 + governs *factory* names, where a `TryXxx` prefix falsely advertised the BCL + `bool`+`out` shape. `Outcome.Try` is not a factory and not a `TryXxx` prefix: + it is a higher-order operation that takes the work as a delegate and returns an + `Outcome`. The delegate argument makes its shape unmistakable, so it borrows + no false promise; the two decisions are orthogonal. + +## Alternatives Considered + +### No primitive — leave callers to write their own try/catch + +Considered because it adds no public surface and imposes no opinion. + +Rejected because it reproduces every failure mode above at every call site with +nothing to catch them, and buries the failure path in imperative plumbing the +library exists to surface. The absence of a sanctioned bridge is what let the +mistakes recur in the first place. + +### A fluent multi-catch builder (`Try(...).Catch(...).Catch(...)`) + +Considered because chaining reads well when several exception types map to +different errors. + +Rejected because it actively invites catching many types — re-introducing the +over-broad-catch hazard as an API affordance — complicates where cancellation is +handled, and serves a case most bridges do not have: the honest majority anticipate +exactly one exception type. The narrow single-type primitive is the 90% tool; +genuinely multi-exception boundaries are better served by an explicit adapter. + +### Automatic exception-to-error conversion (no mandatory mapper) + +Considered for ergonomics — the caller would not have to write a mapper. + +Rejected because the produced error would have no stable code and would carry a +raw exception message, the two things the library's error-code and +error-context conventions exist to prevent. The small ceremony of a mandatory +mapper is the price of a first-class error. + +### Enforce the boundary by narrowing the type surface instead of analyzers + +Considered because making misuse impossible is stronger than flagging it. + +Rejected because legitimate and illegitimate calls share one signature and +differ only by context invisible to the type system; any narrowing that blocked +the misuse would also block the old-framework and third-party cases the +primitive is *for*. The judgment belongs at the use site, which is where an +analyzer speaks. + +## Consequences + +### Positive + +* There is one obvious, safe way to enter the outcome flow from throwing code, + and its default shape is the correct one; the recurring boilerplate collapses + into a single call. +* Errors produced through `Try` keep a mapped, stable code and a curated + message, so they remain first-class. +* The four misuse modes are caught at build time and are tunable per consumer, + so the guard informs without blocking legitimate use. +* Cancellation semantics are preserved without the caller having to think about + them. + +### Negative + +* New public surface — value and side-effecting forms, each synchronous and + asynchronous — to maintain and document on the netstandard2.0 / net472 floor. +* Four usage analyzers (FCE019–FCE022) with their bilingual rule pages and tests + must be kept consistent with the primitive's behaviour. +* The mandatory mapper adds a small, unavoidable ceremony to every call. + +### Risks + +* The analyzers are advisory and suppressible, so a determined misuse can still + ship: the guard reduces error, it does not eliminate it. Mitigation — the two + provable-defect guards (over-broad catch, dead cancellation catch) are on by + default as warnings. +* If the caught-type or cancellation behaviour of `Try` ever changes, the four + analyzers and their documentation must move in lockstep or they will mislead. + Mitigation — the behaviour is pinned by tests. +* A reader may perceive the name `Try` as conflicting with ADR-0005. Mitigation — + this ADR records why the two are orthogonal. + +## Follow-up Actions + +* Keep the EN README/guide and the French translation in sync for the `Try` + guidance and the FCE019–FCE022 rule pages. +* Keep the FCE019–FCE022 analyzers and their tests aligned with the primitive's + documented behaviour whenever either changes. + +## References + +* ADR-0005 — reserve the plain factory name for the Outcome-returning variant + (naming; orthogonal — explains why `Try` here raises no conflict). +* ADR-0022 — floor the library on .NET Framework 4.7.2 (why throw-only + primitives without a `TryXxx` are a real, supported case). +* ADR-0003 — unify Outcome value mapping under Then (Outcome API context). +* [Outcome guide](../../for-users/OutcomeGuide.en.md) — user-facing explanation of + entering and leaving the outcome flow. +* Analyzer rule pages [FCE019](../../for-users/analyzers/FCE019.en.md), + [FCE020](../../for-users/analyzers/FCE020.en.md), + [FCE021](../../for-users/analyzers/FCE021.en.md), + [FCE022](../../for-users/analyzers/FCE022.en.md). diff --git a/doc/handwritten/for-maintainers/adr/0029-complete-the-outcome-try-async-surface-with-token-less-overloads.fr.md b/doc/handwritten/for-maintainers/adr/0029-complete-the-outcome-try-async-surface-with-token-less-overloads.fr.md new file mode 100644 index 00000000..ec665936 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0029-complete-the-outcome-try-async-surface-with-token-less-overloads.fr.md @@ -0,0 +1,174 @@ +# ADR-0029 | Compléter la surface async d'Outcome.Try avec des surcharges sans token + +🌍 🇬🇧 [English](0029-complete-the-outcome-try-async-surface-with-token-less-overloads.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-21 +**Décideurs :** Reefact + +## Contexte + +L'ADR-0028 a introduit `Outcome.Try` avec quatre surcharges : une forme +synchrone à valeur (`Func`), une forme synchrone à effet de bord (`Action`), +et leurs contreparties asynchrones, qui prennent un `CancellationToken` +(`Func>` et `Func`). Sa phrase +de Décision garde le pont étroit « en signalant son mésusage par des analyzers +d'usage plutôt qu'en élargissant l'API », et sa Justification rejette « réduire +la surface de type » — deux arguments cadrés, selon les mots mêmes de l'ADR, aux +mésusages où « appel légitime et illégitime partagent la même signature et ne +diffèrent que par un contexte que le compilateur ne voit pas » (les quatre gardes +sémantiques FCE019–FCE022). + +Une revue de code a ensuite trouvé un défaut qui n'est **pas** de cette nature. +Passer un lambda asynchrone sans paramètre à la surcharge synchrone à effet de +bord — `Outcome.Try(async () => { await …; }, map)` — lie le lambda à `Action` +en **`async void`**, faute d'une surcharge sans token retournant une `Task` qu'il +préférerait. L'`Action` rend la main au premier `await`, donc `Try` ne voit +aucune exception, retourne un succès, et toute exception levée après le premier +`await` s'échappe du `try`/`catch` hors bande — une exception `async void` non +observée qui, typiquement, fait crasher le processus. La même forme abandonne en +silence un fire-and-forget `() => ReturnsTask()`. C'est un défaut **silencieux**, +sur la frontière même que `Try` existe pour sécuriser. + +Trois faits cadrent la correction : + +* Le cas asynchrone **à valeur** ne partage pas le défaut. Un lambda + `async () => … return T` produit une `Task`, qui ne correspond pas à + `Func`, donc c'est une **erreur de compilation** — un garde-fou qui oriente + l'appelant vers la surcharge à token. Seul le cas `Action` à effet de bord se + lie silencieusement. +* Le BCL .NET a résolu ce footgun exact par le **design des surcharges** : + `System.Threading.Tasks.Task.Run` expose `Action`, `Func`, `Func` et + `Func>` côte à côte, précisément pour qu'un lambda asynchrone se lie à + la surcharge retournant une `Task` (awaitée) plutôt qu'en `async void`. +* Le défaut est **visible par le système de types** et n'a **aucun usage + légitime** : personne ne veut passer un lambda async-void à un `Try` synchrone. + +## Décision + +FirstClassErrors ajoute des surcharges sans token `Func` et `Func>` +à `Outcome.Try` pour qu'un lambda asynchrone se lie à un délégué retournant une +`Task` awaitée plutôt qu'à l'`Action` synchrone en `async void`, complétant la +surface async — une correction structurelle que le « plutôt qu'en élargissant +l'API » de l'ADR-0028 n'était pas censé interdire. + +## Justification + +* **Un défaut du système de types mérite une correction du système de types.** + L'accident de liaison naît à la résolution de surcharges, et la règle de + « betterness » de C# fait qu'un lambda asynchrone préfère un délégué retournant + une `Task` à une `Action`. Une fois les surcharges sans token + `Func` / `Func>` présentes, le lambda async s'y lie et est awaité + dans le `try`/`catch` ; le crash devient une impossibilité de compilation plutôt + qu'un danger d'exécution. C'est la bonne altitude — la même sur laquelle le cas + async à valeur s'appuie déjà pour échouer sans risque. +* **Cela colle au précédent BCL qui fait autorité.** `Task.Run` a établi cette + forme exacte à quatre pour ce footgun exact ; la reproduire est le geste + conventionnel et le moins surprenant pour un auteur de bibliothèque .NET, et + cela complète la symétrie sync/async × valeur/effet de bord que l'ADR-0028 a + déjà engagée (les formes async n'existent aujourd'hui que sous forme à token). +* **La préférence-analyzer de l'ADR-0028 ne couvre pas ce cas.** Cette décision + argumente contre la *réduction* de la surface de type pour policer des mésusages + *sémantiques* dont les formes légitime et illégitime partagent une signature. Ce + défaut est l'inverse : la distinction async/sync est visible par le système de + types, le mésusage n'a aucune contrepartie légitime, et la correction *ajoute* + une surcharge au lieu de retirer une capacité — elle ne bloque donc aucun usage + valide et ne déclenche pas l'objection de l'ADR-0028. +* **Un analyzer ne peut pas remplacer la prévention ici.** Un diagnostic + supprimable, en Warning par défaut, qui ne tourne que là où les analyzers sont + activés est le mauvais garde pour un crash de processus *silencieux* — le plus + faible précisément chez les consommateurs .NET Standard 2.0 / .NET Framework + anciens pour qui `Try` existe. Prévenir vaut mieux que détecter quand la panne + est silencieuse. + +## Alternatives envisagées + +### Un analyzer (FCE023) au lieu des surcharges + +Envisagé parce qu'il ne change aucune surface publique et prolonge la famille +FCE019–FCE022 que la bibliothèque livre déjà. + +Rejeté comme garde *primaire* parce qu'il détecte au lieu de prévenir : un crash +de processus silencieux peut quand même être livré là où le warning est coupé ou +supprimé, et l'analyzer doit poursuivre chaque forme syntaxique (lambda async, +expression fire-and-forget) là où la surcharge ferme toute la famille au niveau +du type. Il reste disponible en défense en profondeur optionnelle pour les cas +résiduels non-accidentels ci-dessous, mais n'est pas nécessaire pour fermer le +défaut. + +### N'ajouter que la surcharge à effet de bord `Func` + +Envisagé parce que seul le cas `Action` crashe silencieusement, donc `Func` +seul ferme le défaut tout en évitant l'ambiguïté côté valeur ci-dessous. + +Rejeté parce qu'il laisse la surface async asymétrique — une forme sans token +retournant une `Task` pour le cas à effet de bord mais pas pour le cas à valeur — +sans raison de principe qu'un appelant pourrait prédire. Compléter les deux +reflète `Task.Run` et la grille sync/async × valeur/effet de bord. + +### Laisser le défaut reporté à un suivi + +Envisagé parce que l'ADR-0028 vient d'être accepté et que le changement touche sa +formulation. + +Rejeté parce que livrer une primitive publique de gestion d'erreurs avec un crash +silencieux connu sur sa frontière async n'est pas acceptable ; la bonne réponse +est de consigner le raffinement ici et de laisser le mainteneur l'accepter, pas +de publier le danger. + +## Conséquences + +### Positives + +* Le crash async-void et l'abandon fire-and-forget deviennent structurellement + impossibles pour un lambda asynchrone ; ils sont empêchés à la compilation, pas + seulement diagnostiqués. +* La surface async est symétrique et colle à la forme `Task.Run` du BCL, donc les + lambdas async se comportent de façon prévisible. +* Aucun appel légitime n'est bloqué : les surcharges ajoutent de la capacité au + lieu d'en retirer. + +### Négatives + +* Deux surcharges publiques de plus à maintenir et documenter sur le plancher + netstandard2.0. +* Ajouter `Func>` à côté de `Func` rend un lambda **sans type de retour + naturel** — un `() => throw …` nu ou `() => null` — ambigu entre les deux, un + changement de compatibilité source. Il échoue en **erreur de compilation** + (fail-safe, jamais une surprise à l'exécution) et ne mord que des lambdas + pathologiques, en pratique réservés aux tests ; une vraie opération renvoie une + valeur concrète ou une vraie `Task`. C'est le compromis même que `Task.Run` + assume déjà. + +### Risques + +* Deux cas résiduels, **non-accidentels**, se lient encore à `Action` par leur + type statique : une variable explicitement typée `Action`, et un method-group + `async void` passé par nom. Ni l'un ni l'autre n'est le chemin lambda accidentel + que vise cette décision, et `async void` est déjà largement découragé dans + l'écosystème. Mitigation, si souhaitée plus tard : un analyzer optionnel en + défense en profondeur (voir Alternatives). +* Les surcharges async sans token ne passent aucun `CancellationToken` ; les + appelants qui en ont besoin ont toujours les surcharges à token, qu'un lambda + `ct => …` sélectionne proprement. C'est un manque de confort, pas de correction. + +## Actions de suivi + +* Couvrir les cas lambda-async et fire-and-forget par des tests de non-régression, + et documenter l'ambiguïté des lambdas sans type naturel dans les remarques XML. +* Garder le guide EN/FR et les pages de règle de `Try` synchronisés avec la + surface complétée. +* Décider si le cas résiduel method-group justifie un analyzer optionnel ; fermer + ou reconvertir l'issue de suivi FCE023 en conséquence. + +## Références + +* ADR-0028 — faire entrer le code levant dans les outcomes via un Try encadré (cet + ADR raffine sa clause « plutôt qu'en élargissant l'API » ; il ne change pas la + décision du pont encadré elle-même). +* ADR-0005 — réserver le nom de fabrique simple à la variante retournant un Outcome + (la conservation d'API face à laquelle cette décision est pesée). +* `System.Threading.Tasks.Task.Run` — le précédent BCL pour la forme de surcharges + `Action` / `Func` / `Func` / `Func>`. +* PR #265 ; issue #267 (le suivi async-void reporté que cette décision résout). +* [Guide Outcome](../../for-users/OutcomeGuide.fr.md). diff --git a/doc/handwritten/for-maintainers/adr/0029-complete-the-outcome-try-async-surface-with-token-less-overloads.md b/doc/handwritten/for-maintainers/adr/0029-complete-the-outcome-try-async-surface-with-token-less-overloads.md new file mode 100644 index 00000000..568c53de --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0029-complete-the-outcome-try-async-surface-with-token-less-overloads.md @@ -0,0 +1,164 @@ +# ADR-0029 | Complete the Outcome.Try async surface with token-less overloads + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0029-complete-the-outcome-try-async-surface-with-token-less-overloads.fr.md) + +**Status:** Accepted +**Date:** 2026-07-21 +**Decision Makers:** Reefact + +## Context + +ADR-0028 introduced `Outcome.Try` with four overloads: a synchronous value form +(`Func`), a synchronous side-effecting form (`Action`), and their asynchronous +counterparts, which take a `CancellationToken` (`Func>` +and `Func`). Its Decision sentence keeps the bridge +narrow "by flagging its misuse with usage analyzers rather than by broadening the +API", and its Rationale rejects "narrowing the type surface" — both arguments +scoped, in the ADR's own words, to misuses where "legitimate and illegitimate +calls share one signature and differ only by context the compiler cannot see" +(the four semantic guards FCE019–FCE022). + +A code review then found a defect that is *not* of that kind. Passing a +parameterless asynchronous lambda to the synchronous side-effecting overload — +`Outcome.Try(async () => { await …; }, map)` — binds the lambda to `Action` +as **`async void`**, because no token-less `Task`-returning overload exists for +it to prefer. The `Action` returns at the first `await`, so `Try` observes no +exception, returns success, and any exception raised after the first `await` +escapes the `try`/`catch` out-of-band — an unobserved `async void` exception that +typically crashes the process. The same shape silently drops a fire-and-forget +`() => ReturnsTask()`. This is a **silent** defect on the very boundary `Try` +exists to make safe. + +Three facts frame the fix: + +* The asynchronous **value** case does not share the defect. An + `async () => … return T` lambda produces a `Task`, which does not match + `Func`, so it is a **compile error** — a fail-safe that steers the caller to + the token overload. Only the side-effecting `Action` case binds silently. +* The .NET BCL solved this exact footgun by **overload design**: + `System.Threading.Tasks.Task.Run` ships `Action`, `Func`, `Func` and + `Func>` side by side precisely so an asynchronous lambda binds to the + awaited `Task`-returning overload instead of `async void`. +* The defect is **visible to the type system** and has **no legitimate use**: + nobody intends to pass an async-void lambda to a synchronous `Try`. + +## Decision + +FirstClassErrors adds token-less `Func` and `Func>` overloads to +`Outcome.Try` so an asynchronous lambda binds to an awaited `Task`-returning +delegate rather than to the synchronous `Action` as `async void`, completing the +async surface — a structural correctness fix that ADR-0028's "rather than +broadening the API" was not meant to preclude. + +## Rationale + +* **A type-system defect deserves a type-system fix.** The binding accident is + created at overload resolution, and C# betterness makes an asynchronous lambda + prefer a `Task`-returning delegate over `Action`. Once the token-less + `Func` / `Func>` overloads exist, the async lambda binds there and + is awaited inside the `try`/`catch`; the crash becomes a compile-time + impossibility rather than a runtime hazard. This is the correct altitude — the + same one the async value case already relies on to fail safely. +* **It matches the governing BCL precedent.** `Task.Run` established this exact + four-way shape for this exact footgun; reproducing it is the conventional, + least-surprising move for a .NET library author, and it completes the + sync/async × value/side-effecting symmetry ADR-0028 already committed to (the + async forms exist today only in token-taking shape). +* **ADR-0028's analyzer preference does not reach this case.** That decision + argues against *narrowing* the type surface to police *semantic* misuses whose + legitimate and illegitimate forms share a signature. This defect is the + opposite: the async-vs-sync distinction is visible to the type system, the + misuse has no legitimate counterpart, and the fix *adds* an overload rather + than removing capability — so it blocks zero valid uses and does not trigger + ADR-0028's objection. +* **An analyzer cannot substitute for prevention here.** A suppressible, + Warning-by-default diagnostic that only runs where analyzers are enabled is the + wrong guard for a *silent* process crash — weakest precisely in the older + .NET Standard 2.0 / .NET Framework consumers `Try` exists for. Prevention beats + detection when the failure is silent. + +## Alternatives Considered + +### An analyzer (FCE023) instead of overloads + +Considered because it changes no public surface and continues the FCE019–FCE022 +family the library already ships. + +Rejected as the *primary* guard because it detects rather than prevents: a +silent process crash can still ship where the warning is off or suppressed, and +the analyzer must chase each syntactic shape (async lambda, fire-and-forget +expression) whereas the overload closes the whole family at the type layer. It +remains available as optional defence-in-depth for the residual non-accidental +cases below, but is not needed to close the defect. + +### Add only the side-effecting `Func` overload + +Considered because only the `Action` case crashes silently, so `Func` alone +closes the defect while avoiding the value-side ambiguity below. + +Rejected because it leaves the async surface asymmetric — a token-less +`Task`-returning form for the side-effecting case but not the value case — for no +principled reason a caller could predict. Completing both mirrors `Task.Run` and +the sync/async × value/side-effecting grid. + +### Leave the defect deferred to a follow-up + +Considered because ADR-0028 is freshly accepted and the change touches its +wording. + +Rejected because shipping a public error-handling primitive with a known silent +crash on its async boundary is not acceptable; the correct response is to record +the refinement here and let the maintainer accept it, not to release the hazard. + +## Consequences + +### Positive + +* The async-void crash and the fire-and-forget drop become structurally + impossible for an asynchronous lambda; they are prevented at compile time, not + merely diagnosed. +* The async surface is symmetric and matches the BCL's `Task.Run` shape, so async + lambdas behave predictably. +* No legitimate call is blocked: the overloads add capability rather than + removing it. + +### Negative + +* Two more public overloads to maintain and document on the netstandard2.0 floor. +* Adding `Func>` beside `Func` makes a lambda with **no natural return + type** — a bare `() => throw …` or `() => null` — ambiguous between the two, a + source-compatibility change. It fails as a **compile error** (fail-safe, never a + runtime surprise) and bites only pathological, effectively test-only lambdas; + a real operation returns a concrete value or a real `Task`. This is the same + tradeoff `Task.Run` already accepts. + +### Risks + +* Two residual, **non-accidental** cases still bind to `Action` by their static + type: an explicitly `Action`-typed variable, and an `async void` method group + passed by name. Neither is the accidental lambda path this decision targets, and + `async void` is already broadly discouraged across the ecosystem. Mitigation, if + desired later: an optional analyzer as defence-in-depth (see Alternatives). +* The token-less async overloads thread no `CancellationToken`; callers needing a + token still have the existing token overloads, which a `ct => …` lambda selects + cleanly. This is a convenience gap, not a correctness one. + +## Follow-up Actions + +* Cover the async-lambda and fire-and-forget cases with regression tests, and + document the ambiguity of natural-type-less lambdas in the XML remarks. +* Keep the EN/FR guide and the Try rule pages in sync with the completed surface. +* Decide whether the residual method-group case warrants an optional analyzer; + close or repurpose the FCE023 tracking issue accordingly. + +## References + +* ADR-0028 — bridge throwing code into outcomes through a guarded Try (this ADR + refines its "rather than broadening the API" clause; it does not change the + guarded-bridge decision itself). +* ADR-0005 — reserve the plain factory name for the Outcome-returning variant + (API-conservatism this decision is weighed against). +* `System.Threading.Tasks.Task.Run` — the BCL precedent for the `Action` / + `Func` / `Func` / `Func>` overload shape. +* PR #265; issue #267 (the deferred async-void follow-up this decision resolves). +* [Outcome guide](../../for-users/OutcomeGuide.en.md). diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 21f871c4..764ef113 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -210,3 +210,5 @@ Optional supporting material: | [ADR-0025](0025-generate-strings-from-a-home-grown-regular-subset.md) | Generate matching strings from a home-grown regular subset | Proposed | | [ADR-0026](0026-rebase-testing-arbitrary-values-on-dummies.md) | Rebase the testing package's arbitrary values on Dummies | Accepted | | [ADR-0027](0027-repair-dependabot-pull-requests-within-a-risk-boundary.md) | Repair Dependabot pull requests within a risk boundary | Accepted | +| [ADR-0028](0028-bridge-throwing-code-into-outcomes-through-a-guarded-try.md) | Bridge throwing code into outcomes through a guarded Try | Accepted | +| [ADR-0029](0029-complete-the-outcome-try-async-surface-with-token-less-overloads.md) | Complete the Outcome.Try async surface with token-less overloads | Accepted | diff --git a/doc/handwritten/for-users/OutcomeGuide.en.md b/doc/handwritten/for-users/OutcomeGuide.en.md index eec289ac..f80a331e 100644 --- a/doc/handwritten/for-users/OutcomeGuide.en.md +++ b/doc/handwritten/for-users/OutcomeGuide.en.md @@ -140,6 +140,57 @@ result.Finally( `Finally` is terminal: use it to consume or translate an outcome, not as a hidden intermediate step in a long chain. +## Entering the outcome flow from throwing code + +`Try` is the inverse of `ThrowIfFailure()` and `GetResultOrThrow()`: instead of leaving the outcome flow by throwing, it *enters* it by running a throwing operation and capturing the throw as a failure through a mandatory mapper. It is a **narrow, sharp tool**, not the default way to reach the outcome flow — most of the time one of the cases below applies and `Try` is the wrong choice. Four analyzers back these boundaries: [FCE019](analyzers/FCE019.en.md), [FCE021](analyzers/FCE021.en.md) and [FCE022](analyzers/FCE022.en.md) are on by default; [FCE020](analyzers/FCE020.en.md) is opt-in. + +### When `Try` is the wrong tool + +**A non-throwing `TryXxx` already exists.** Most parsing and conversion in the BCL has a `bool TryParse(..., out T)` / `TryCreate` counterpart. Use it and map the `false`: there is no exception to catch, so `Try` only adds cost and noise. [FCE021](analyzers/FCE021.en.md) warns on this whenever the counterpart exists for your target framework (suppress it where a look-alike `TryXxx` is not a true inverse). + +```csharp +// Don't: catch what you can avoid throwing. +Outcome port = Outcome.Try(() => int.Parse(raw), PortErrors.Malformed); + +// Do: the non-throwing path is already there. +Outcome port = int.TryParse(raw, out int value) + ? Outcome.Success(value) + : Outcome.Failure(PortErrors.Malformed(raw)); +``` + +**The failure is a protocol result, not (only) a throw.** An HTTP call or a database command signals failure through a status code, a provider error number, a response body — data you already hold. Wrapping it in `Try` throws that away: a 404, a 402 and a timeout collapse into one error, and the timeout even slips out as the `OperationCanceledException` that `Try` lets through. These deserve a dedicated adapter that inspects the result, not a caught throw. + +**You reach for `System.Exception`.** That swallows bugs — a null dereference, an invalid state — into anticipated errors, the one thing `Outcome` is documented not to represent. Catch the single type the operation is documented to throw. + +### When `Try` fits + +What is left is real but narrow: **a throwing call you do not own and cannot give a `TryXxx`, that has none available** for your target framework, where one exception type denotes the anticipated failure. Two things land you here — an older framework (`MailAddress.TryCreate` is .NET 5+, `DateOnly.TryParse` is .NET 6+, so on the netstandard2.0 / .NET Framework floor the primitive is throw-only), or a third-party library that never shipped a `TryXxx`. If it were your own code, you would add the non-throwing form instead of catching. + +```csharp +public static Outcome Load(byte[] der) { + return Outcome.Try( + () => new Certificate(der), + exception => CertificateErrors.Unreadable(exception)); +} +``` + +`JsonDocument.Parse` outside an HTTP flow (a config file, a queue message), `XDocument.Parse`, and a third-party `Parse`/`Decode` that only throws are the other honest fits. (Prefer `JsonDocument.Parse` over `JsonSerializer.Deserialize` here: the latter returns `null` for the JSON literal `null`, which becomes an `ArgumentNullException` from `Success` rather than the `JsonException` you catch.) + +### The rules `Try` always follows + +- **The mapper is mandatory.** The library never converts an exception to an error automatically: that would yield errors without a stable code (what [FCE005](analyzers/FCE005.en.md) discourages), and the mapper is where you extract only what is safe rather than leaking the raw message (FCE017/FCE018). +- **Only the named type is caught**; anything else propagates, because an `Outcome` models an anticipated failure, not an unexpected crash. +- **`OperationCanceledException` always propagates**, even when the caught type is `Exception`: a cancellation is not a failure. Binding `TException` to a cancellation type therefore produces a catch that can never run — a silent dead catch that [FCE022](analyzers/FCE022.en.md) flags. + +A side-effecting operation returns a non-generic `Outcome`, and both forms have an asynchronous overload that threads a cancellation token: + +```csharp +Outcome config = await Outcome.Try( + (ct) => LoadConfigAsync(path, ct), + exception => ConfigErrors.Malformed(path, exception), + cancellationToken); +``` + ## Leaving the outcome flow Two methods convert a failure back into exception flow. @@ -234,6 +285,7 @@ Before approving an `Outcome` flow, verify that: - factories remain the single source of error construction; - each composition step uses `Then`, returning a value to map or an `Outcome` to chain a step that may fail; - recovery is intentional and does not silently discard useful failures; +- throwing code is brought into the flow through `Try` with a mandatory mapper, only where the exception is the whole failure signal; - exception conversion occurs only at a clear boundary; - a fluent chain is genuinely clearer than explicit branching; - cancellation tokens are propagated through asynchronous operations. diff --git a/doc/handwritten/for-users/OutcomeGuide.fr.md b/doc/handwritten/for-users/OutcomeGuide.fr.md index fc8f8576..4701452d 100644 --- a/doc/handwritten/for-users/OutcomeGuide.fr.md +++ b/doc/handwritten/for-users/OutcomeGuide.fr.md @@ -140,6 +140,57 @@ result.Finally( `Finally` est terminal : utilisez-le pour consommer ou traduire un outcome, pas comme une étape intermédiaire cachée dans une longue chaîne. +## Entrer dans le flux Outcome depuis du code qui lève + +`Try` est l'inverse de `ThrowIfFailure()` et `GetResultOrThrow()` : au lieu de sortir du flux Outcome en levant, elle y *entre* en exécutant une opération susceptible de lever et en capturant la levée comme un échec via un mapper obligatoire. C'est un **outil étroit et tranchant**, pas la façon par défaut d'atteindre le flux Outcome — la plupart du temps, l'un des cas ci-dessous s'applique et `Try` est le mauvais choix. Quatre analyzers épaulent ces frontières : [FCE019](analyzers/FCE019.fr.md), [FCE021](analyzers/FCE021.fr.md) et [FCE022](analyzers/FCE022.fr.md) sont activés par défaut ; [FCE020](analyzers/FCE020.fr.md) est opt-in. + +### Quand `Try` est le mauvais outil + +**Un `TryXxx` non-levant existe déjà.** La plupart des parsings et conversions de la BCL ont un équivalent `bool TryParse(..., out T)` / `TryCreate`. Utilisez-le et mappez le `false` : il n'y a aucune exception à attraper, donc `Try` n'ajoute que du coût et du bruit. [FCE021](analyzers/FCE021.fr.md) émet un warning là-dessus dès que la contrepartie existe pour votre framework cible (supprimez-le là où un `TryXxx` sosie n'est pas un vrai inverse). + +```csharp +// À éviter : attraper ce que vous pouvez éviter de lever. +Outcome port = Outcome.Try(() => int.Parse(raw), PortErrors.Malformed); + +// À faire : le chemin non-levant est déjà là. +Outcome port = int.TryParse(raw, out int value) + ? Outcome.Success(value) + : Outcome.Failure(PortErrors.Malformed(raw)); +``` + +**L'échec est un résultat de protocole, pas (seulement) une levée.** Un appel HTTP ou une commande base de données signale l'échec via un code de statut, un numéro d'erreur du provider, un corps de réponse — des données que vous avez déjà en main. Les envelopper dans `Try` jette tout cela : un 404, un 402 et un timeout s'effondrent en une seule erreur, et le timeout s'échappe même sous la forme de l'`OperationCanceledException` que `Try` laisse passer. Ces cas méritent un adaptateur dédié qui inspecte le résultat, pas une levée attrapée. + +**Vous dégainez `System.Exception`.** Cela avale les bugs — un déréférencement null, un état invalide — en erreurs anticipées, la seule chose qu'un `Outcome` est documenté pour ne pas représenter. Attrapez le seul type que l'opération est documentée pour lever. + +### Quand `Try` convient + +Ce qui reste est réel mais étroit : **un appel que vous ne possédez pas et auquel vous ne pouvez pas ajouter de `TryXxx`, qui n'en a aucun de disponible** pour votre framework cible, où un seul type d'exception dénote l'échec anticipé. Deux choses vous y amènent — un framework ancien (`MailAddress.TryCreate` est .NET 5+, `DateOnly.TryParse` est .NET 6+, donc sur le plancher netstandard2.0 / .NET Framework la primitive est levante-seulement), ou une lib tierce qui n'a jamais livré de `TryXxx`. Si c'était votre propre code, vous ajouteriez la forme non-levante au lieu d'attraper. + +```csharp +public static Outcome Load(byte[] der) { + return Outcome.Try( + () => new Certificate(der), + exception => CertificateErrors.Unreadable(exception)); +} +``` + +`JsonDocument.Parse` hors d'un flux HTTP (un fichier de config, un message de file), `XDocument.Parse`, et un `Parse`/`Decode` tiers qui ne fait que lever sont les autres cas honnêtes. (Préférez `JsonDocument.Parse` à `JsonSerializer.Deserialize` ici : ce dernier retourne `null` pour le littéral JSON `null`, ce qui devient une `ArgumentNullException` depuis `Success` plutôt que la `JsonException` que vous attrapez.) + +### Les règles que `Try` respecte toujours + +- **Le mapper est obligatoire.** La bibliothèque ne convertit jamais une exception en erreur automatiquement : cela produirait des erreurs sans code stable (ce que [FCE005](analyzers/FCE005.fr.md) décourage), et le mapper est l'endroit où extraire uniquement ce qui est sûr plutôt que de déverser le message brut (FCE017/FCE018). +- **Seul le type nommé est attrapé** ; tout le reste se propage, car un `Outcome` modélise un échec anticipé, pas un crash inattendu. +- **`OperationCanceledException` se propage toujours**, même lorsque le type attrapé est `Exception` : une annulation n'est pas un échec. Lier `TException` à un type d'annulation produit donc un catch qui ne peut jamais s'exécuter — un catch mort silencieux que [FCE022](analyzers/FCE022.fr.md) signale. + +Une opération à effet de bord renvoie un `Outcome` non générique, et les deux formes disposent d'une surcharge asynchrone qui propage un token d'annulation : + +```csharp +Outcome config = await Outcome.Try( + (ct) => LoadConfigAsync(path, ct), + exception => ConfigErrors.Malformed(path, exception), + cancellationToken); +``` + ## Sortir du flux Outcome Deux méthodes reconvertissent un échec en flux par exception. @@ -234,6 +285,7 @@ Avant de valider un flux `Outcome`, vérifiez que : - les factories restent la source unique de construction des erreurs ; - chaque étape de composition utilise `Then`, en renvoyant une valeur pour transformer ou un `Outcome` pour chaîner une étape susceptible d’échouer ; - la récupération est intentionnelle et ne supprime pas silencieusement des erreurs utiles ; +- le code qui lève est ramené dans le flux via `Try` avec un mapper obligatoire, uniquement là où l’exception est tout le signal d’échec ; - la conversion en exception n’a lieu qu’à une frontière claire ; - une chaîne fluide est réellement plus lisible qu’un branchement explicite ; - les tokens d’annulation sont propagés dans les opérations asynchrones. diff --git a/doc/handwritten/for-users/analyzers/FCE019.en.md b/doc/handwritten/for-users/analyzers/FCE019.en.md new file mode 100644 index 00000000..a24b064f --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE019.en.md @@ -0,0 +1,50 @@ +# FCE019: TryCatchesTooBroadly + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./FCE019.fr.md) + +| | | +|---|---| +| **Category** | Usage (`FirstClassErrors.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +`Outcome.Try` exists to catch the **single** exception type that denotes an anticipated failure and let everything else propagate. Catching `System.Exception` turns unexpected bugs — a null dereference, an invalid state, an out-of-memory — into anticipated errors, which is exactly what `Outcome` is documented *not* to represent. It also hides which failure the operation is actually expected to produce. The rule flags exactly `System.Exception`; other near-root bases such as `SystemException` are broad too but uncommon in practice, so they are left to review rather than flagged, keeping the rule free of false positives. + +## Noncompliant + +```csharp +Outcome cert = Outcome.Try( // FCE019 + () => new Certificate(der), + // a bug in the constructor (NullReferenceException, IndexOutOfRangeException, ...) is now + // mislabeled "Unreadable" instead of surfacing as the crash it is + exception => CertificateErrors.Unreadable(exception)); +``` + +## Compliant + +Catch the specific exception the operation is documented to throw: + +```csharp +Outcome cert = Outcome.Try( + () => new Certificate(der), + exception => CertificateErrors.Unreadable(exception)); +``` + +If a boundary genuinely must turn *every* failure into one error (for example a last-resort process guard), write that intent explicitly with your own `try`/`catch` rather than hiding it behind `Try`, whose contract is a single anticipated exception. + +## Suppressing + +For the rare intentional broad catch, silence the single line: + +```csharp +#pragma warning disable FCE019 // last-resort boundary: every failure maps to one error +Outcome result = Outcome.Try(RunPlugin, PluginErrors.Crashed); +#pragma warning restore FCE019 +``` + +**Related:** [FCE020](FCE020.en.md), [FCE021](FCE021.en.md), [FCE022](FCE022.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/FCE019.fr.md b/doc/handwritten/for-users/analyzers/FCE019.fr.md new file mode 100644 index 00000000..ba7451e5 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE019.fr.md @@ -0,0 +1,50 @@ +# FCE019: TryCatchesTooBroadly + +🌍 **Langues:** +🇬🇧 [English](./FCE019.en.md) | 🇫🇷 Français (ce fichier) + +| | | +|---|---| +| **Catégorie** | Usage (`FirstClassErrors.Usage`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +`Outcome.Try` existe pour attraper le **seul** type d'exception qui dénote un échec anticipé et laisser tout le reste se propager. Attraper `System.Exception` transforme des bugs inattendus — un déréférencement null, un état invalide, un dépassement mémoire — en erreurs anticipées, c'est-à-dire exactement ce qu'un `Outcome` est documenté pour **ne pas** représenter. Cela masque aussi quel échec l'opération est réellement censée produire. La règle signale exactement `System.Exception` ; d'autres bases proches de la racine comme `SystemException` sont larges aussi mais rares en pratique, donc laissées à la revue plutôt que signalées, ce qui garde la règle sans faux positifs. + +## Non conforme + +```csharp +Outcome cert = Outcome.Try( // FCE019 + () => new Certificate(der), + // un bug dans le constructeur (NullReferenceException, IndexOutOfRangeException, …) est désormais + // requalifié « Unreadable » au lieu de remonter comme le crash qu'il est + exception => CertificateErrors.Unreadable(exception)); +``` + +## Conforme + +Attrapez l'exception spécifique que l'opération est documentée pour lever : + +```csharp +Outcome cert = Outcome.Try( + () => new Certificate(der), + exception => CertificateErrors.Unreadable(exception)); +``` + +Si une frontière doit réellement transformer *chaque* échec en une seule erreur (par exemple un garde-fou de dernier recours au niveau du processus), écrivez cette intention explicitement avec votre propre `try`/`catch` plutôt que de la cacher derrière `Try`, dont le contrat est une exception anticipée unique. + +## Faire taire la règle + +Pour le rare catch large intentionnel, faites taire la ligne : + +```csharp +#pragma warning disable FCE019 // frontière de dernier recours : tout échec est mappé sur une erreur +Outcome result = Outcome.Try(RunPlugin, PluginErrors.Crashed); +#pragma warning restore FCE019 +``` + +**Voir aussi:** [FCE020](FCE020.fr.md), [FCE021](FCE021.fr.md), [FCE022](FCE022.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/FCE020.en.md b/doc/handwritten/for-users/analyzers/FCE020.en.md new file mode 100644 index 00000000..f9271944 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE020.en.md @@ -0,0 +1,54 @@ +# FCE020: TryCatchesRichProtocolException + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./FCE020.fr.md) + +| | | +|---|---| +| **Category** | Usage (`FirstClassErrors.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | No (opt-in) | + +`Outcome.Try` reduces a failure to "it threw", which is the wrong shape for a protocol boundary. HTTP, socket, and database failures carry their real signal *beside* the exception: a status code, a provider error number, a response body. One exception type (`HttpRequestException`, `DbException`, ...) spans several distinct failures with different transience, and a request timeout surfaces as an `OperationCanceledException` that `Try` deliberately lets through. A dedicated secondary-port adapter that inspects the result keeps all of that by construction. The mapper *can* still read the status back off the caught exception, so this rule is a prompt to weigh a dedicated adapter — not a claim that the data is necessarily lost. + +This rule fires when the caught type is, or derives from, `System.Net.Http.HttpRequestException`, `System.Net.WebException`, `System.Net.Sockets.SocketException`, or `System.Data.Common.DbException`. + +## Noncompliant + +```csharp +Outcome receipt = Outcome.Try( // FCE020 + () => _paymentGateway.GetReceipt(id), + exception => PaymentErrors.Unreachable(exception)); +``` + +The 404, the 402, the 500, and the timeout all collapse into one error, and the status code that told them apart is gone. + +## Compliant + +Inspect the result at a dedicated adapter, mapping the status (data, not an exception) to a specific error, and reserve `Try` for the throwing primitives that have no such result to read: + +```csharp +HttpResponseMessage response = await _httpClient.GetAsync($"receipts/{id}", cancellationToken); + +Outcome receipt = response.StatusCode switch { + HttpStatusCode.OK => await ReadReceipt(response, cancellationToken), + HttpStatusCode.PaymentRequired => Outcome.Failure(PaymentErrors.Declined(id)), + HttpStatusCode.NotFound => Outcome.Failure(PaymentErrors.NotFound(id)), // permanent + HttpStatusCode.ServiceUnavailable => Outcome.Failure(PaymentErrors.TemporarilyUnavailable()), // transient — safe to retry + _ => Outcome.Failure(PaymentErrors.Unexpected(response.StatusCode)), +}; +``` + +## Enabling this rule + +This rule is disabled by default. Turn it on in `.editorconfig`: + +```ini +dotnet_diagnostic.FCE020.severity = warning # or suggestion / error +``` + +**Related:** [FCE019](FCE019.en.md), [FCE021](FCE021.en.md), [FCE022](FCE022.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/FCE020.fr.md b/doc/handwritten/for-users/analyzers/FCE020.fr.md new file mode 100644 index 00000000..6c4098ab --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE020.fr.md @@ -0,0 +1,54 @@ +# FCE020: TryCatchesRichProtocolException + +🌍 **Langues:** +🇬🇧 [English](./FCE020.en.md) | 🇫🇷 Français (ce fichier) + +| | | +|---|---| +| **Catégorie** | Usage (`FirstClassErrors.Usage`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Non (opt-in) | + +`Outcome.Try` réduit un échec à « ça a levé », ce qui est la mauvaise forme pour une frontière de protocole. Les échecs HTTP, socket et base de données portent leur vrai signal *à côté* de l'exception : un code de statut, un numéro d'erreur du provider, un corps de réponse. Un même type d'exception (`HttpRequestException`, `DbException`, …) recouvre plusieurs échecs distincts de transiences différentes, et un timeout de requête se présente comme une `OperationCanceledException` que `Try` laisse délibérément passer. Un adaptateur de port secondaire dédié qui inspecte le résultat conserve tout cela par construction. Le mapper *peut* encore relire le statut depuis l'exception attrapée, donc cette règle est une invitation à envisager un adaptateur dédié — pas l'affirmation que la donnée est forcément perdue. + +Cette règle se déclenche quand le type attrapé est, ou dérive de, `System.Net.Http.HttpRequestException`, `System.Net.WebException`, `System.Net.Sockets.SocketException` ou `System.Data.Common.DbException`. + +## Non conforme + +```csharp +Outcome receipt = Outcome.Try( // FCE020 + () => _paymentGateway.GetReceipt(id), + exception => PaymentErrors.Unreachable(exception)); +``` + +Le 404, le 402, le 500 et le timeout s'effondrent tous en une seule erreur, et le code de statut qui les distinguait a disparu. + +## Conforme + +Inspectez le résultat dans un adaptateur dédié, en mappant le statut (une donnée, pas une exception) vers une erreur précise, et réservez `Try` aux primitives levantes qui n'ont pas de tel résultat à lire : + +```csharp +HttpResponseMessage response = await _httpClient.GetAsync($"receipts/{id}", cancellationToken); + +Outcome receipt = response.StatusCode switch { + HttpStatusCode.OK => await ReadReceipt(response, cancellationToken), + HttpStatusCode.PaymentRequired => Outcome.Failure(PaymentErrors.Declined(id)), + HttpStatusCode.NotFound => Outcome.Failure(PaymentErrors.NotFound(id)), // permanente + HttpStatusCode.ServiceUnavailable => Outcome.Failure(PaymentErrors.TemporarilyUnavailable()), // transitoire — réessayable + _ => Outcome.Failure(PaymentErrors.Unexpected(response.StatusCode)), +}; +``` + +## Activer cette règle + +Cette règle est désactivée par défaut. Activez-la dans `.editorconfig` : + +```ini +dotnet_diagnostic.FCE020.severity = warning # ou suggestion / error +``` + +**Voir aussi:** [FCE019](FCE019.fr.md), [FCE021](FCE021.fr.md), [FCE022](FCE022.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/FCE021.en.md b/doc/handwritten/for-users/analyzers/FCE021.en.md new file mode 100644 index 00000000..0de8655b --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE021.en.md @@ -0,0 +1,67 @@ +# FCE021: PreferNonThrowingAlternativeToTry + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./FCE021.fr.md) + +| | | +|---|---| +| **Category** | Usage (`FirstClassErrors.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +`Outcome.Try` catches an exception so you can turn it into an error. But when the wrapped call already has a non-throwing counterpart — a `bool TryParse(..., out T)` or a `TryCreate` with a matching signature — there is usually no exception worth catching. Mapping the counterpart's `false` result is cheaper and clearer, so `Try` here often just adds overhead and hides the better path. + +The rule is **framework-aware**: it fires only when the counterpart actually resolves, with a compatible signature, in the compilation being analyzed. A target framework that does not expose it — a .NET Standard 2.0 or .NET Framework consumer, where `MailAddress.TryCreate` (.NET 5+) or `DateOnly.TryParse` (.NET 6+) simply do not exist — is never flagged, because there `Try` is the right tool. A multi-targeted project is analyzed once per target framework, so the same call can be flagged on `net8.0` and left alone on `netstandard2.0`. + +Detection is limited to a lambda whose body is a *single* throwing call or object creation, so the suggested rewrite stays an exact one-for-one replacement. + +## Noncompliant + +```csharp +Outcome port = Outcome.Try( // FCE021 + () => int.Parse(raw), + exception => PortErrors.Malformed(raw, exception)); +``` + +## Compliant + +```csharp +Outcome port = int.TryParse(raw, out int value) + ? Outcome.Success(value) + : Outcome.Failure(PortErrors.Malformed(raw)); +``` + +The rule fires wherever a matching counterpart resolves — for the BCL, a third-party library, **or your own types**. It does not try to guess where you can or cannot add a `TryXxx`; it surfaces every `Try` that wraps a call already paired with one. + +## It is advisory — confirm before you rewrite + +The suggestion is **not a guarantee of equivalence**. Structural matching cannot see behaviour, so before you act on a flag, confirm: + +- the counterpart accepts and rejects the same inputs — a look-alike `TryCreate` that normalizes its input, or a `TryParse` that uses a different culture, is *not* the non-throwing form of your call even though it matches by shape; +- it reports the same failures you catch — `int.Parse`, for example, also throws on overflow, which `int.TryParse` folds into a `false` your narrow `catch (FormatException)` would have let propagate; +- you do not need the caught exception's diagnostics — the `TryXxx` form has no exception to hand your mapper. + +Where the counterpart is genuinely the non-throwing form (the common case), take the suggestion. Where it is not, **suppress it in place** and move on: + +```csharp +#pragma warning disable FCE021 // Slug.TryCreate normalizes input; it is not the non-throwing form of this ctor +Outcome slug = Outcome.Try(() => new Slug(raw), SlugErrors.Malformed); +#pragma warning restore FCE021 + +// or on the whole member: +[SuppressMessage("FirstClassErrors.Usage", "FCE021", Justification = "TryCreate normalizes the input")] +``` + +## Tuning the rule + +It is on by default as a **warning**. Escalate, relax, or turn it off in `.editorconfig`: + +```ini +dotnet_diagnostic.FCE021.severity = error # or suggestion / none +``` + +**Related:** [FCE019](FCE019.en.md), [FCE020](FCE020.en.md), [FCE022](FCE022.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/FCE021.fr.md b/doc/handwritten/for-users/analyzers/FCE021.fr.md new file mode 100644 index 00000000..6bdee614 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE021.fr.md @@ -0,0 +1,67 @@ +# FCE021: PreferNonThrowingAlternativeToTry + +🌍 **Langues:** +🇬🇧 [English](./FCE021.en.md) | 🇫🇷 Français (ce fichier) + +| | | +|---|---| +| **Catégorie** | Usage (`FirstClassErrors.Usage`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +`Outcome.Try` attrape une exception pour que vous puissiez la transformer en erreur. Mais quand l'appel enveloppé a déjà une contrepartie non-levante — un `bool TryParse(..., out T)` ou un `TryCreate` de signature compatible — il n'y a généralement aucune exception qui vaille la peine d'être attrapée. Mapper le résultat `false` de la contrepartie est plus économique et plus clair ; ici `Try` ne fait souvent qu'ajouter du surcoût et masquer le meilleur chemin. + +La règle est **framework-aware** : elle ne se déclenche que si la contrepartie résout réellement, avec une signature compatible, dans la compilation analysée. Un framework cible qui ne l'expose pas — un consommateur .NET Standard 2.0 ou .NET Framework, où `MailAddress.TryCreate` (.NET 5+) ou `DateOnly.TryParse` (.NET 6+) n'existent tout simplement pas — n'est jamais signalé, car là `Try` est le bon outil. Un projet multi-cible est analysé une fois par framework cible, donc le même appel peut être signalé sur `net8.0` et laissé tranquille sur `netstandard2.0`. + +La détection se limite à un lambda dont le corps est un *seul* appel levant ou une création d'objet, pour que la réécriture suggérée reste un remplacement exact, un pour un. + +## Non conforme + +```csharp +Outcome port = Outcome.Try( // FCE021 + () => int.Parse(raw), + exception => PortErrors.Malformed(raw, exception)); +``` + +## Conforme + +```csharp +Outcome port = int.TryParse(raw, out int value) + ? Outcome.Success(value) + : Outcome.Failure(PortErrors.Malformed(raw)); +``` + +La règle se déclenche partout où une contrepartie compatible existe — pour la BCL, une lib tierce, **ou vos propres types**. Elle n'essaie pas de deviner où vous pouvez ou non ajouter un `TryXxx` ; elle fait remonter chaque `Try` qui enveloppe un appel déjà associé à une forme non-levante. + +## C'est un conseil — vérifiez avant de réécrire + +La suggestion n'est **pas une garantie d'équivalence**. La correspondance structurelle ne voit pas le comportement ; avant d'agir sur un signalement, vérifiez : + +- la contrepartie accepte et rejette les mêmes entrées — un `TryCreate` sosie qui normalise son entrée, ou un `TryParse` qui applique une autre culture, n'est *pas* la forme non-levante de votre appel même s'il colle par la forme ; +- elle signale les mêmes échecs que ceux que vous attrapez — `int.Parse`, par exemple, lève aussi en cas d'overflow, que `int.TryParse` replie en un `false` que votre `catch (FormatException)` étroit aurait laissé se propager ; +- vous n'avez pas besoin des diagnostics de l'exception attrapée — la forme `TryXxx` n'a aucune exception à passer à votre mapper. + +Là où la contrepartie est réellement la forme non-levante (le cas courant), suivez la suggestion. Là où elle ne l'est pas, **supprimez-la sur place** et passez : + +```csharp +#pragma warning disable FCE021 // Slug.TryCreate normalise l'entrée ; ce n'est pas la forme non-levante de ce ctor +Outcome slug = Outcome.Try(() => new Slug(raw), SlugErrors.Malformed); +#pragma warning restore FCE021 + +// ou sur tout le membre : +[SuppressMessage("FirstClassErrors.Usage", "FCE021", Justification = "TryCreate normalise l'entrée")] +``` + +## Régler la règle + +Elle est activée par défaut en **warning**. Montez-la, adoucissez-la, ou coupez-la dans `.editorconfig` : + +```ini +dotnet_diagnostic.FCE021.severity = error # ou suggestion / none +``` + +**Voir aussi:** [FCE019](FCE019.fr.md), [FCE020](FCE020.fr.md), [FCE022](FCE022.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/FCE022.en.md b/doc/handwritten/for-users/analyzers/FCE022.en.md new file mode 100644 index 00000000..82c5f3f3 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE022.en.md @@ -0,0 +1,71 @@ +# FCE022: TryCatchesCancellation + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./FCE022.fr.md) + +| | | +|---|---| +| **Category** | Usage (`FirstClassErrors.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +`Outcome.Try` guards its catch with `when (exception is not OperationCanceledException)`: a cancellation is **never** turned into an error — it always propagates. Binding `TException` to `OperationCanceledException`, or to a subtype such as `TaskCanceledException`, therefore produces a catch that can never engage. The exception filter becomes a contradiction — *is an `OperationCanceledException`* **and** *is not one* — so the mapper never runs and no `Outcome` is ever produced from a cancellation. + +Unlike an unreachable ordinary `catch`, the compiler does not flag this. An exception filter is evaluated at run time, so `catch (OperationCanceledException) when (e is not OperationCanceledException)` — which is exactly what `Try` compiles to — is valid, silent, and dead. This rule surfaces the dead catch at build time. + +The mistake almost always appears in **async** code: you thread a `CancellationToken` into `Try`, decide you should "handle cancellation" too, and reach for `OperationCanceledException` as the caught type. That is precisely the one type `Try` will not hand you. + +## Noncompliant + +```csharp +public static async Task> Load(string path, CancellationToken cancellationToken) { + // We thread a token, so we "handle cancellation" here — but this catch can never run. + return await Outcome.Try( // FCE022 + ct => LoadConfigAsync(path, ct), + exception => ConfigErrors.Cancelled(exception), // never runs + cancellationToken); +} +``` + +The same defect hides behind the common subtype — `TaskCanceledException : OperationCanceledException`, so it is ruled out by the very same filter: + +```csharp +return await Outcome.Try( // FCE022 + ct => LoadConfigAsync(path, ct), + exception => ConfigErrors.Cancelled(exception), // never runs + cancellationToken); +``` + +## Compliant + +Cancellation cannot be modelled as a `Try` failure — it is not an anticipated error, it is a request to stop. Split the two concerns. Let `Try` catch the exception the operation is genuinely expected to throw; cancellation flows past it, by design: + +```csharp +public static async Task> Load(string path, CancellationToken cancellationToken) { + return await Outcome.Try( + ct => LoadConfigAsync(path, ct), + exception => ConfigErrors.Malformed(path, exception), + cancellationToken); +} +``` + +Then handle the cancellation you meant to catch at the caller, where stopping actually means something: + +```csharp +try { + Outcome config = await ConfigLoader.Load(path, cancellationToken); + // ... use the outcome +} catch (OperationCanceledException) { + // stop cleanly: cancellation is control flow, not an Outcome failure +} +``` + +## This is not advisory + +Unlike [FCE021](FCE021.en.md), which suggests a *possibly* better shape, FCE022 marks code that provably does nothing: the catch it describes can never run on any input. There is no legitimate `Outcome.Try<…, OperationCanceledException>`. Rather than suppress it, delete the cancellation handling or replace the type parameter with the exception you actually mean to catch. + +**Related:** [FCE019](FCE019.en.md), [FCE020](FCE020.en.md), [FCE021](FCE021.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/FCE022.fr.md b/doc/handwritten/for-users/analyzers/FCE022.fr.md new file mode 100644 index 00000000..697ec2ee --- /dev/null +++ b/doc/handwritten/for-users/analyzers/FCE022.fr.md @@ -0,0 +1,71 @@ +# FCE022: TryCatchesCancellation + +🌍 **Langues:** +🇬🇧 [English](./FCE022.en.md) | 🇫🇷 Français (ce fichier) + +| | | +|---|---| +| **Catégorie** | Usage (`FirstClassErrors.Usage`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +`Outcome.Try` garde son catch avec `when (exception is not OperationCanceledException)` : une annulation n'est **jamais** transformée en erreur — elle se propage toujours. Lier `TException` à `OperationCanceledException`, ou à un sous-type comme `TaskCanceledException`, produit donc un catch qui ne peut jamais s'enclencher. Le filtre d'exception devient une contradiction — *est une `OperationCanceledException`* **et** *n'en est pas une* — donc le mapper ne s'exécute jamais et aucun `Outcome` n'est jamais produit à partir d'une annulation. + +Contrairement à un `catch` ordinaire inatteignable, le compilateur ne le signale pas. Un filtre d'exception est évalué à l'exécution, donc `catch (OperationCanceledException) when (e is not OperationCanceledException)` — exactement ce que compile `Try` — est valide, silencieux, et mort. Cette règle fait remonter le catch mort à la compilation. + +La faute apparaît presque toujours en **async** : vous passez un `CancellationToken` à `Try`, vous décidez qu'il faut aussi « gérer l'annulation », et vous prenez `OperationCanceledException` comme type attrapé. C'est précisément le seul type que `Try` ne vous passera pas. + +## Non conforme + +```csharp +public static async Task> Load(string path, CancellationToken cancellationToken) { + // On passe un token, donc on « gère l'annulation » ici — mais ce catch ne peut jamais s'exécuter. + return await Outcome.Try( // FCE022 + ct => LoadConfigAsync(path, ct), + exception => ConfigErrors.Cancelled(exception), // ne s'exécute jamais + cancellationToken); +} +``` + +Le même défaut se cache derrière le sous-type courant — `TaskCanceledException : OperationCanceledException`, donc écarté par le même filtre : + +```csharp +return await Outcome.Try( // FCE022 + ct => LoadConfigAsync(path, ct), + exception => ConfigErrors.Cancelled(exception), // ne s'exécute jamais + cancellationToken); +``` + +## Conforme + +Une annulation ne peut pas se modéliser comme un échec de `Try` — ce n'est pas une erreur anticipée, c'est une demande d'arrêt. Séparez les deux préoccupations. Laissez `Try` attraper l'exception que l'opération est réellement censée lever ; l'annulation le traverse, comme prévu : + +```csharp +public static async Task> Load(string path, CancellationToken cancellationToken) { + return await Outcome.Try( + ct => LoadConfigAsync(path, ct), + exception => ConfigErrors.Malformed(path, exception), + cancellationToken); +} +``` + +Puis gérez l'annulation que vous vouliez traiter à la frontière, là où « s'arrêter » a réellement un sens : + +```csharp +try { + Outcome config = await ConfigLoader.Load(path, cancellationToken); + // ... utiliser l'outcome +} catch (OperationCanceledException) { + // arrêt propre : l'annulation est du contrôle de flux, pas un échec Outcome +} +``` + +## Ce n'est pas un conseil + +Contrairement à [FCE021](FCE021.fr.md), qui suggère une forme *peut-être* meilleure, FCE022 marque du code qui, de façon prouvée, ne fait rien : le catch qu'il décrit ne peut s'exécuter sur aucune entrée. Il n'existe aucun `Outcome.Try<…, OperationCanceledException>` légitime. Plutôt que de le supprimer, retirez la gestion de l'annulation ou remplacez le paramètre de type par l'exception que vous voulez réellement attraper. + +**Voir aussi:** [FCE019](FCE019.fr.md), [FCE020](FCE020.fr.md), [FCE021](FCE021.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/README.fr.md b/doc/handwritten/for-users/analyzers/README.fr.md index 141360b4..991ab31a 100644 --- a/doc/handwritten/for-users/analyzers/README.fr.md +++ b/doc/handwritten/for-users/analyzers/README.fr.md @@ -44,6 +44,10 @@ Chaque règle a un identifiant stable `FCExxx`. Les erreurs sont des défauts du | [FCE016 UnusedToExceptionResult](FCE016.fr.md) | 🟠 Warning | activée | Error.ToException() est appelé comme instruction isolée, ou son résultat est explicitement ignoré avec `_ =`. | | [FCE017 SensitiveDataInErrorContext](FCE017.fr.md) | 🟠 Warning | opt-in | Le nom d'une ErrorContextKey désigne un secret, un identifiant d'authentification ou une donnée personnelle (mot de passe, token, secret, chaîne de connexion, carte bancaire, …). | | [FCE018 OversizedErrorContextValue](FCE018.fr.md) | 🔵 Info | opt-in | Le type de valeur d'une ErrorContextKey est un gros payload (tableau d'octets, Stream ou FileInfo) qui n'a pas sa place dans un contexte destiné aux logs. | +| [FCE019 TryCatchesTooBroadly](FCE019.fr.md) | 🟠 Warning | activée | Outcome.Try attrape System.Exception, transformant des bugs inattendus en erreurs anticipées au lieu de la seule exception que l'opération est censée lever. | +| [FCE020 TryCatchesRichProtocolException](FCE020.fr.md) | 🟠 Warning | opt-in | Outcome.Try attrape un échec de protocole (HttpRequestException, DbException, SocketException, …) dont la donnée de statut ou de résultat est perdue une fois réduite à une levée. | +| [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. | ## Configuration diff --git a/doc/handwritten/for-users/analyzers/README.md b/doc/handwritten/for-users/analyzers/README.md index dd6e729b..9012272f 100644 --- a/doc/handwritten/for-users/analyzers/README.md +++ b/doc/handwritten/for-users/analyzers/README.md @@ -44,6 +44,10 @@ Each rule has a stable id `FCExxx`. Errors are hard defects; warnings flag likel | [FCE016 UnusedToExceptionResult](FCE016.en.md) | 🟠 Warning | on | Error.ToException() is called as a standalone statement, or its result is explicitly discarded with `_ =`. | | [FCE017 SensitiveDataInErrorContext](FCE017.en.md) | 🟠 Warning | opt-in | An ErrorContextKey name denotes a secret, credential, or personal data (password, token, secret, connection string, credit card, ...). | | [FCE018 OversizedErrorContextValue](FCE018.en.md) | 🔵 Info | opt-in | An ErrorContextKey value type is a bulk payload (byte array, Stream, or FileInfo) that does not belong in a loggable context. | +| [FCE019 TryCatchesTooBroadly](FCE019.en.md) | 🟠 Warning | on | Outcome.Try catches System.Exception, turning unexpected bugs into anticipated errors instead of the single exception the operation is expected to throw. | +| [FCE020 TryCatchesRichProtocolException](FCE020.en.md) | 🟠 Warning | opt-in | Outcome.Try catches a protocol failure (HttpRequestException, DbException, SocketException, ...) whose status or result data is lost when reduced to a throw. | +| [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. | ## Configuring