diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml new file mode 100644 index 0000000..dbd4a0f --- /dev/null +++ b/.github/workflows/analyzers.yml @@ -0,0 +1,38 @@ +name: analyzers + +on: + push: + branches: + - analyzers + - 'claude/**' + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + build-test: + name: Build & test analyzers + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj + + - name: Build + run: dotnet build FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj -c Release --no-restore + + - name: Test + run: dotnet test FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj -c Release --no-build --logger "console;verbosity=detailed" + + # Dogfood: build the sample with the analyzers wired in. Fails on any Error-severity FCExxx and surfaces + # the warnings in the log. Doc generation is off here so this stays a pure analyzer check. + - name: Build usage (dogfood analyzers) + run: dotnet build FirstClassErrors.Usage/FirstClassErrors.Usage.csproj -c Release -p:GenerateErrorDocumentation=false diff --git a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs new file mode 100644 index 0000000..c6f5af3 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -0,0 +1,66 @@ +using System.Collections.Immutable; + +using FirstClassErrors; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +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. +/// +internal static class AnalyzerTestHarness { + + private static readonly ImmutableArray BaseReferences = BuildBaseReferences(); + + public static async Task> GetDiagnosticsAsync( + DiagnosticAnalyzer analyzer, + string source, + params string[] enabledDiagnosticIds) { + + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source); + + CSharpCompilationOptions options = new(OutputKind.DynamicallyLinkedLibrary); + if (enabledDiagnosticIds.Length > 0) { + // Force otherwise opt-in (isEnabledByDefault: false) rules on for the test, as an .editorconfig would. + ImmutableDictionary.Builder specific = ImmutableDictionary.CreateBuilder(); + foreach (string id in enabledDiagnosticIds) { specific[id] = ReportDiagnostic.Info; } + options = options.WithSpecificDiagnosticOptions(specific.ToImmutable()); + } + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "FirstClassErrors.Analyzers.TestSnippet", + syntaxTrees: new[] { syntaxTree }, + references: BaseReferences, + options: options); + + CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + return await withAnalyzers.GetAnalyzerDiagnosticsAsync(); + } + + private static ImmutableArray BuildBaseReferences() { + List references = new(); + + // Reference the running runtime's assemblies so snippets resolve System types without pinning a ref pack. + string trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty; + foreach (string path in trustedAssemblies.Split(Path.PathSeparator)) { + if (string.IsNullOrEmpty(path)) { continue; } + try { + references.Add(MetadataReference.CreateFromFile(path)); + } catch { + // Skip any native or otherwise unloadable entry in the TPA list. + } + } + + // The FirstClassErrors core, so ErrorCode / DomainError / DescribeError resolve inside the snippet. + references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location)); + + return references.ToImmutableArray(); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce001DuplicateErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce001DuplicateErrorCodeTests.cs new file mode 100644 index 0000000..c4b6598 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce001DuplicateErrorCodeTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Immutable; +using System.Linq; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce001DuplicateErrorCodeTests { + + [Fact] + public async Task Reports_each_site_when_same_code_is_created_twice() { + const string source = """ + using FirstClassErrors; + + public static class First { + public static readonly ErrorCode A = ErrorCode.Create("DUP"); + } + + public static class Second { + public static readonly ErrorCode B = ErrorCode.Create("DUP"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(2); + Check.That(diagnostics.All(d => d.Id == "FCE001")).IsTrue(); + } + + [Fact] + public async Task Does_not_report_for_distinct_codes() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create("ONE"); + public static readonly ErrorCode B = ErrorCode.Create("TWO"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_for_a_single_use() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create("ONLY"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Ignores_duplicate_empty_codes_which_belong_to_FCE002() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create(""); + public static readonly ErrorCode B = ErrorCode.Create(""); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Ignores_non_literal_codes() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + private static string Build() => "DYNAMIC"; + public static readonly ErrorCode A = ErrorCode.Create(Build()); + public static readonly ErrorCode B = ErrorCode.Create(Build()); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs new file mode 100644 index 0000000..f90b180 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs @@ -0,0 +1,76 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce002EmptyErrorCodeTests { + + [Fact] + public async Task Reports_on_empty_string_literal() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static readonly ErrorCode Code = ErrorCode.Create(""); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE002"); + } + + [Fact] + public async Task Reports_on_whitespace_string_literal() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static readonly ErrorCode Code = ErrorCode.Create(" "); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE002"); + } + + [Fact] + public async Task Does_not_report_on_valid_literal() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static readonly ErrorCode Code = ErrorCode.Create("VALID_CODE"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_on_non_literal_argument() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + private static string Build() => "DYNAMIC_CODE"; + public static readonly ErrorCode Code = ErrorCode.Create(Build()); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce003NonLiteralErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce003NonLiteralErrorCodeTests.cs new file mode 100644 index 0000000..6b850f3 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce003NonLiteralErrorCodeTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce003NonLiteralErrorCodeTests { + + [Fact] + public async Task Reports_when_code_is_computed_at_runtime() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + private static string Build() => "DYNAMIC"; + public static readonly ErrorCode A = ErrorCode.Create(Build()); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new NonLiteralErrorCodeAnalyzer(), source, "FCE003"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE003"); + } + + [Fact] + public async Task Does_not_report_for_a_literal_code() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create("LITERAL_CODE"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new NonLiteralErrorCodeAnalyzer(), source, "FCE003"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_for_a_constant_reference() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + private const string Value = "CONST_CODE"; + public static readonly ErrorCode A = ErrorCode.Create(Value); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new NonLiteralErrorCodeAnalyzer(), source, "FCE003"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce004InvalidErrorCodeFormatTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce004InvalidErrorCodeFormatTests.cs new file mode 100644 index 0000000..cf61921 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce004InvalidErrorCodeFormatTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce004InvalidErrorCodeFormatTests { + + [Fact] + public async Task Reports_when_code_is_not_upper_snake_case() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create("moneyTransferInvalid"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InvalidErrorCodeFormatAnalyzer(), source, "FCE004"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE004"); + } + + [Fact] + public async Task Does_not_report_for_upper_snake_case() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InvalidErrorCodeFormatAnalyzer(), source, "FCE004"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce005TooGenericErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce005TooGenericErrorCodeTests.cs new file mode 100644 index 0000000..e8ad3ac --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce005TooGenericErrorCodeTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce005TooGenericErrorCodeTests { + + [Fact] + public async Task Reports_a_generic_code() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create("INVALID"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TooGenericErrorCodeAnalyzer(), source, "FCE005"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE005"); + } + + [Fact] + public async Task Does_not_report_a_specific_code() { + const string source = """ + using FirstClassErrors; + + public static class Codes { + public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TooGenericErrorCodeAnalyzer(), source, "FCE005"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs new file mode 100644 index 0000000..6c78631 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce006DocumentedByTargetNotFoundTests { + + [Fact] + public async Task Reports_when_target_method_does_not_exist() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy("Nope")] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByTargetNotFoundAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE006"); + } + + [Fact] + public async Task Does_not_report_when_target_method_exists() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByTargetNotFoundAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_method_has_no_documented_by() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByTargetNotFoundAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs new file mode 100644 index 0000000..0608e6c --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs @@ -0,0 +1,95 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce007DocumentedByInvalidSignatureTests { + + [Fact] + public async Task Reports_when_target_has_wrong_return_type() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static string Doc() => "not a documentation"; + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE007"); + } + + [Fact] + public async Task Reports_when_target_is_not_static() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE007"); + } + + [Fact] + public async Task Does_not_report_for_valid_documentation_method() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_target_is_missing() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy("Nope")] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs new file mode 100644 index 0000000..bcd949e --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce008DocumentedByWithoutProvidesErrorsForTests { + + [Fact] + public async Task Reports_when_documented_type_lacks_provides_errors_for() { + const string source = """ + using FirstClassErrors; + + public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByWithoutProvidesErrorsForAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE008"); + } + + [Fact] + public async Task Does_not_report_when_provides_errors_for_is_present() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByWithoutProvidesErrorsForAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_type_has_no_documented_factory() { + const string source = """ + using FirstClassErrors; + + public static class SampleError { + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByWithoutProvidesErrorsForAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce009ErrorFactoryNotDocumentedTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce009ErrorFactoryNotDocumentedTests.cs new file mode 100644 index 0000000..d375a2e --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce009ErrorFactoryNotDocumentedTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce009ErrorFactoryNotDocumentedTests { + + [Fact] + public async Task Reports_undocumented_factory_in_provides_errors_for_type() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE009"); + } + + [Fact] + public async Task Does_not_report_documented_factory() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_type_is_not_a_provider() { + const string source = """ + using FirstClassErrors; + + public static class SampleError { + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_private_helper() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + private static DomainError Helper() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce010MultipleFactoriesShareDocumentationTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce010MultipleFactoriesShareDocumentationTests.cs new file mode 100644 index 0000000..8baf5a3 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce010MultipleFactoriesShareDocumentationTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce010MultipleFactoriesShareDocumentationTests { + + [Fact] + public async Task Reports_each_factory_sharing_a_documentation_method() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError A() => + DomainError.Create(ErrorCode.Create("A"), "diagnostic").WithPublicMessage("short"); + + [DocumentedBy(nameof(Doc))] + internal static DomainError B() => + DomainError.Create(ErrorCode.Create("B"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new MultipleFactoriesShareDocumentationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(2); + Check.That(diagnostics.All(d => d.Id == "FCE010")).IsTrue(); + } + + [Fact] + public async Task Does_not_report_when_each_factory_has_its_own_documentation() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(DocA))] + internal static DomainError A() => + DomainError.Create(ErrorCode.Create("A"), "diagnostic").WithPublicMessage("short"); + + [DocumentedBy(nameof(DocB))] + internal static DomainError B() => + DomainError.Create(ErrorCode.Create("B"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation DocA() => + DescribeError.WithTitle("a").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + + private static ErrorDocumentation DocB() => + DescribeError.WithTitle("b").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new MultipleFactoriesShareDocumentationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce011DuplicateDocumentedCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce011DuplicateDocumentedCodeTests.cs new file mode 100644 index 0000000..d9af1fc --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce011DuplicateDocumentedCodeTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; +using System.Linq; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce011DuplicateDocumentedCodeTests { + + [Fact] + public async Task Reports_when_two_documented_factories_share_a_code_field() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(DocA))] + internal static DomainError A() => + DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short"); + + [DocumentedBy(nameof(DocB))] + internal static DomainError B() => + DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation DocA() => + DescribeError.WithTitle("a").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + + private static ErrorDocumentation DocB() => + DescribeError.WithTitle("b").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + + private static class Code { + public static readonly ErrorCode Shared = ErrorCode.Create("SHARED"); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateDocumentedCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(2); + Check.That(diagnostics.All(d => d.Id == "FCE011")).IsTrue(); + } + + [Fact] + public async Task Does_not_report_when_documented_factories_use_distinct_code_fields() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + [DocumentedBy(nameof(DocA))] + internal static DomainError A() => + DomainError.Create(Code.A, "diagnostic").WithPublicMessage("short"); + + [DocumentedBy(nameof(DocB))] + internal static DomainError B() => + DomainError.Create(Code.B, "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation DocA() => + DescribeError.WithTitle("a").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + + private static ErrorDocumentation DocB() => + DescribeError.WithTitle("b").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + + private static class Code { + public static readonly ErrorCode A = ErrorCode.Create("A"); + public static readonly ErrorCode B = ErrorCode.Create("B"); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateDocumentedCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_the_sharing_factories_are_undocumented() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + internal static DomainError A() => + DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short"); + + internal static DomainError B() => + DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short"); + + private static class Code { + public static readonly ErrorCode Shared = ErrorCode.Create("SHARED"); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateDocumentedCodeAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce012EmptyExamplesTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce012EmptyExamplesTests.cs new file mode 100644 index 0000000..de3a6a3 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce012EmptyExamplesTests.cs @@ -0,0 +1,51 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce012EmptyExamplesTests { + + [Fact] + public async Task Reports_when_with_examples_has_no_factory() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyExamplesAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE012"); + } + + [Fact] + public async Task Does_not_report_when_an_example_is_provided() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(() => Boom()); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyExamplesAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce013ExampleDoesNotCallDocumentedFactoryTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce013ExampleDoesNotCallDocumentedFactoryTests.cs new file mode 100644 index 0000000..f3a076b --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce013ExampleDoesNotCallDocumentedFactoryTests.cs @@ -0,0 +1,75 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce013ExampleDoesNotCallDocumentedFactoryTests { + + [Fact] + public async Task Reports_when_example_does_not_call_a_factory_of_the_documenting_type() { + const string source = """ + using FirstClassErrors; + + public static class Other { + public static DomainError Build() => + DomainError.Create(ErrorCode.Create("OTHER"), "diagnostic").WithPublicMessage("short"); + } + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic() + .WithExamples(() => Other.Build()); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ExampleDoesNotCallDocumentedFactoryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE013"); + } + + [Fact] + public async Task Does_not_report_when_example_calls_a_factory_of_the_documenting_type() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + internal static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short"); + + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic() + .WithExamples(() => Boom()); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ExampleDoesNotCallDocumentedFactoryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_there_are_no_examples() { + const string source = """ + using FirstClassErrors; + + [ProvidesErrorsFor("Sample")] + public static class SampleError { + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ExampleDoesNotCallDocumentedFactoryAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce014ShortMessageSameAsDetailedMessageTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce014ShortMessageSameAsDetailedMessageTests.cs new file mode 100644 index 0000000..e754431 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce014ShortMessageSameAsDetailedMessageTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce014ShortMessageSameAsDetailedMessageTests { + + [Fact] + public async Task Reports_when_short_and_detailed_are_identical() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("Same message", "Same message"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ShortMessageSameAsDetailedMessageAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE014"); + } + + [Fact] + public async Task Does_not_report_when_messages_differ() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("Short", "A longer detail"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ShortMessageSameAsDetailedMessageAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_only_short_message_is_given() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static DomainError Boom() => + DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("Short only"); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ShortMessageSameAsDetailedMessageAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce015DocumentationTitleTooGenericTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce015DocumentationTitleTooGenericTests.cs new file mode 100644 index 0000000..b9c5b06 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce015DocumentationTitleTooGenericTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce015DocumentationTitleTooGenericTests { + + [Fact] + public async Task Reports_a_generic_title() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("Invalid value").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentationTitleTooGenericAnalyzer(), source, "FCE015"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE015"); + } + + [Fact] + public async Task Does_not_report_a_specific_title() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + private static ErrorDocumentation Doc() => + DescribeError.WithTitle("Temperature below absolute zero").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentationTitleTooGenericAnalyzer(), source, "FCE015"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce016UnusedToExceptionResultTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce016UnusedToExceptionResultTests.cs new file mode 100644 index 0000000..679a706 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/Fce016UnusedToExceptionResultTests.cs @@ -0,0 +1,65 @@ +using System.Collections.Immutable; + +using FirstClassErrors.Analyzers; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +public class Fce016UnusedToExceptionResultTests { + + [Fact] + public async Task Reports_when_result_is_discarded() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static void M(DomainError error) { + error.ToException(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedToExceptionResultAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("FCE016"); + } + + [Fact] + public async Task Does_not_report_when_thrown() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static void M(DomainError error) { + throw error.ToException(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedToExceptionResultAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_result_is_captured() { + const string source = """ + using FirstClassErrors; + + public static class Sample { + public static void M(DomainError error) { + var exception = error.ToException(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedToExceptionResultAnalyzer(), 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 new file mode 100644 index 0000000..db4a19a --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..5998171 --- /dev/null +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,23 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|--------------------------------------|----------|------------------------------------- +FCE001 | FirstClassErrors.ErrorCodes | Error | DuplicateErrorCodeAnalyzer +FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer +FCE003 | FirstClassErrors.ErrorCodes | Disabled | NonLiteralErrorCodeAnalyzer +FCE004 | FirstClassErrors.ErrorCodes | Disabled | InvalidErrorCodeFormatAnalyzer +FCE005 | FirstClassErrors.ErrorCodes | Disabled | TooGenericErrorCodeAnalyzer +FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer +FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer +FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer +FCE009 | FirstClassErrors.DocumentationWiring | Warning | ErrorFactoryNotDocumentedAnalyzer +FCE010 | FirstClassErrors.DocumentationWiring | Warning | MultipleFactoriesShareDocumentationAnalyzer +FCE011 | FirstClassErrors.DocumentationContent| Error | DuplicateDocumentedCodeAnalyzer +FCE012 | FirstClassErrors.DocumentationContent| Warning | EmptyExamplesAnalyzer +FCE013 | FirstClassErrors.DocumentationContent| Warning | ExampleDoesNotCallDocumentedFactoryAnalyzer +FCE014 | FirstClassErrors.DocumentationContent| Info | ShortMessageSameAsDetailedMessageAnalyzer +FCE015 | FirstClassErrors.DocumentationContent| Disabled | DocumentationTitleTooGenericAnalyzer +FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs new file mode 100644 index 0000000..bc0540f --- /dev/null +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -0,0 +1,173 @@ +using Microsoft.CodeAnalysis; + +namespace FirstClassErrors.Analyzers; + +/// +/// The for every FirstClassErrors rule. One field per FCExxx, added as the +/// rule is implemented. +/// +internal static class Descriptors { + + public static readonly DiagnosticDescriptor DuplicateErrorCode = new( + id: DiagnosticIds.DuplicateErrorCode, + title: "Duplicate error code", + messageFormat: "Error code '{0}' is created more than once; each ErrorCode must be unique", + category: DiagnosticCategories.ErrorCodes, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "ErrorCode.Create registers each code in a process-wide set and throws when the same code is created twice. Detection is per-compilation and limited to literal codes.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateErrorCode), + customTags: new[] { WellKnownDiagnosticTags.CompilationEnd }); + + public static readonly DiagnosticDescriptor EmptyErrorCode = new( + id: DiagnosticIds.EmptyErrorCode, + title: "Error code must not be empty", + messageFormat: "Error code must not be null, empty or whitespace", + category: DiagnosticCategories.ErrorCodes, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "ErrorCode.Create requires a non-empty code; an empty or whitespace literal throws an ArgumentException at runtime.", + helpLinkUri: HelpLinks.For(DiagnosticIds.EmptyErrorCode)); + + public static readonly DiagnosticDescriptor NonLiteralErrorCode = new( + id: DiagnosticIds.NonLiteralErrorCode, + title: "Error code is not a compile-time literal", + messageFormat: "Error code is computed at runtime; duplicate-code analysis (FCE001) cannot verify it", + category: DiagnosticCategories.ErrorCodes, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "Only literal error codes can be checked statically. A code built at runtime is a blind spot for duplicate detection; this rule is opt-in for teams that want codes to stay literal.", + helpLinkUri: HelpLinks.For(DiagnosticIds.NonLiteralErrorCode)); + + public static readonly DiagnosticDescriptor InvalidErrorCodeFormat = new( + id: DiagnosticIds.InvalidErrorCodeFormat, + title: "Error code does not follow the UPPER_SNAKE_CASE convention", + messageFormat: "Error code '{0}' does not match the expected UPPER_SNAKE_CASE format", + category: DiagnosticCategories.ErrorCodes, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "A consistent code format keeps catalogs and logs scannable. This convention check is opt-in.", + helpLinkUri: HelpLinks.For(DiagnosticIds.InvalidErrorCodeFormat)); + + public static readonly DiagnosticDescriptor TooGenericErrorCode = new( + id: DiagnosticIds.TooGenericErrorCode, + title: "Error code is too generic", + messageFormat: "Error code '{0}' is too generic to identify a specific failure", + category: DiagnosticCategories.ErrorCodes, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "A code such as ERROR or INVALID carries no diagnostic value. Prefer a code that names the specific condition. Opt-in.", + helpLinkUri: HelpLinks.For(DiagnosticIds.TooGenericErrorCode)); + + public static readonly DiagnosticDescriptor DocumentedByTargetNotFound = new( + id: DiagnosticIds.DocumentedByTargetNotFound, + title: "Documentation method referenced by [DocumentedBy] was not found", + messageFormat: "No method named '{0}' exists on the type; [DocumentedBy] cannot be resolved and this error will not be documented", + category: DiagnosticCategories.DocumentationWiring, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "[DocumentedBy] references its documentation method by name; a name that resolves to nothing is silently skipped when documentation is extracted.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentedByTargetNotFound)); + + public static readonly DiagnosticDescriptor DocumentedByInvalidSignature = new( + id: DiagnosticIds.DocumentedByInvalidSignature, + title: "[DocumentedBy] target has an invalid signature", + messageFormat: "Method '{0}' must be static, parameterless and return ErrorDocumentation to be used by [DocumentedBy]", + category: DiagnosticCategories.DocumentationWiring, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The documentation factory referenced by [DocumentedBy] is invoked as a static parameterless method returning ErrorDocumentation; any other shape is skipped at extraction time.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentedByInvalidSignature)); + + public static readonly DiagnosticDescriptor DocumentedByWithoutProvidesErrorsFor = new( + id: DiagnosticIds.DocumentedByWithoutProvidesErrorsFor, + title: "[DocumentedBy] used in a type without [ProvidesErrorsFor]", + messageFormat: "Type '{0}' declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]; its error documentation will be silently ignored", + category: DiagnosticCategories.DocumentationWiring, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Documentation extraction only scans types annotated with [ProvidesErrorsFor]; [DocumentedBy] methods on an unannotated type are never extracted.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentedByWithoutProvidesErrorsFor)); + + public static readonly DiagnosticDescriptor UnusedToExceptionResult = new( + id: DiagnosticIds.UnusedToExceptionResult, + title: "The result of ToException() is not used", + messageFormat: "The result of ToException() is discarded; did you mean to throw it?", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "ToException() only builds an exception; discarding it as a standalone statement means nothing is thrown and the error is lost.", + helpLinkUri: HelpLinks.For(DiagnosticIds.UnusedToExceptionResult)); + + public static readonly DiagnosticDescriptor ErrorFactoryNotDocumented = new( + id: DiagnosticIds.ErrorFactoryNotDocumented, + title: "Error factory is not documented", + messageFormat: "Factory '{0}' returns an error but has no [DocumentedBy]; it will not appear in the generated documentation", + category: DiagnosticCategories.DocumentationWiring, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A non-private static factory in a [ProvidesErrorsFor] type that returns an Error is expected to carry [DocumentedBy]; without it the error is left out of the generated catalog.", + helpLinkUri: HelpLinks.For(DiagnosticIds.ErrorFactoryNotDocumented)); + + public static readonly DiagnosticDescriptor MultipleFactoriesShareDocumentation = new( + id: DiagnosticIds.MultipleFactoriesShareDocumentation, + title: "Multiple factories share the same documentation", + messageFormat: "Documentation method '{0}' is referenced by more than one factory; each error should have its own documentation", + category: DiagnosticCategories.DocumentationWiring, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "One documentation method describes one error (its title, description and examples). Sharing it between factories means at least one error is mis-documented.", + helpLinkUri: HelpLinks.For(DiagnosticIds.MultipleFactoriesShareDocumentation)); + + public static readonly DiagnosticDescriptor EmptyExamples = new( + id: DiagnosticIds.EmptyExamples, + title: "Documentation declares no examples", + messageFormat: "WithExamples was called without any example factory; add at least one representative example", + category: DiagnosticCategories.DocumentationContent, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Examples expose the real messages an error produces; calling WithExamples with no factory yields documentation that shows none.", + helpLinkUri: HelpLinks.For(DiagnosticIds.EmptyExamples)); + + public static readonly DiagnosticDescriptor DuplicateDocumentedCode = new( + id: DiagnosticIds.DuplicateDocumentedCode, + title: "Duplicate documented error code", + messageFormat: "Error code '{0}' is produced by more than one documented factory; documentation extraction keeps only one of them", + category: DiagnosticCategories.DocumentationContent, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Documentation extraction groups by error code and keeps a single entry per code. Two documented factories that share the same code field silently collapse to one in the catalog.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateDocumentedCode), + customTags: new[] { WellKnownDiagnosticTags.CompilationEnd }); + + public static readonly DiagnosticDescriptor ExampleDoesNotCallDocumentedFactory = new( + id: DiagnosticIds.ExampleDoesNotCallDocumentedFactory, + title: "Documentation example does not construct the documented error", + messageFormat: "This example does not call any factory of '{0}'; an example should build the error it documents", + category: DiagnosticCategories.DocumentationContent, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Examples are meant to expose the real messages of the documented error, so each should invoke a factory of the type that declares the documentation.", + helpLinkUri: HelpLinks.For(DiagnosticIds.ExampleDoesNotCallDocumentedFactory)); + + public static readonly DiagnosticDescriptor ShortMessageSameAsDetailedMessage = new( + id: DiagnosticIds.ShortMessageSameAsDetailedMessage, + title: "Short message duplicates the detailed message", + messageFormat: "The short public message is identical to the detailed message; give the caller a shorter summary", + category: DiagnosticCategories.DocumentationContent, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "The short message is a public summary and the detailed message an optional public detail; making them identical usually signals a copy-paste.", + helpLinkUri: HelpLinks.For(DiagnosticIds.ShortMessageSameAsDetailedMessage)); + + public static readonly DiagnosticDescriptor DocumentationTitleTooGeneric = new( + id: DiagnosticIds.DocumentationTitleTooGeneric, + title: "Documentation title is too generic", + messageFormat: "Documentation title '{0}' is too generic; state what the error is", + category: DiagnosticCategories.DocumentationContent, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "A title such as \"Error\" or \"Invalid value\" tells the reader nothing. A good title names the condition (e.g. \"Temperature below absolute zero\"). Opt-in.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentationTitleTooGeneric)); + +} diff --git a/FirstClassErrors.Analyzers/DiagnosticCategories.cs b/FirstClassErrors.Analyzers/DiagnosticCategories.cs new file mode 100644 index 0000000..17ca0d7 --- /dev/null +++ b/FirstClassErrors.Analyzers/DiagnosticCategories.cs @@ -0,0 +1,13 @@ +namespace FirstClassErrors.Analyzers; + +/// +/// Categories used to group FirstClassErrors diagnostics in the IDE and in .editorconfig. +/// +internal static class DiagnosticCategories { + + public const string ErrorCodes = "FirstClassErrors.ErrorCodes"; + public const string DocumentationWiring = "FirstClassErrors.DocumentationWiring"; + public const string DocumentationContent = "FirstClassErrors.DocumentationContent"; + public const string Usage = "FirstClassErrors.Usage"; + +} diff --git a/FirstClassErrors.Analyzers/DiagnosticIds.cs b/FirstClassErrors.Analyzers/DiagnosticIds.cs new file mode 100644 index 0000000..f8c4026 --- /dev/null +++ b/FirstClassErrors.Analyzers/DiagnosticIds.cs @@ -0,0 +1,33 @@ +namespace FirstClassErrors.Analyzers; + +/// +/// Stable identifiers for every FirstClassErrors diagnostic. The number is only a stable handle; rules are grouped +/// for the user through , not through contiguous numbering. +/// +internal static class DiagnosticIds { + + // Category: ErrorCodes + public const string DuplicateErrorCode = "FCE001"; + public const string EmptyErrorCode = "FCE002"; + public const string NonLiteralErrorCode = "FCE003"; + public const string InvalidErrorCodeFormat = "FCE004"; + public const string TooGenericErrorCode = "FCE005"; + + // Category: DocumentationWiring + public const string DocumentedByTargetNotFound = "FCE006"; + public const string DocumentedByInvalidSignature = "FCE007"; + public const string DocumentedByWithoutProvidesErrorsFor = "FCE008"; + public const string ErrorFactoryNotDocumented = "FCE009"; + public const string MultipleFactoriesShareDocumentation = "FCE010"; + + // Category: DocumentationContent + public const string DuplicateDocumentedCode = "FCE011"; + public const string EmptyExamples = "FCE012"; + public const string ExampleDoesNotCallDocumentedFactory = "FCE013"; + public const string ShortMessageSameAsDetailedMessage = "FCE014"; + public const string DocumentationTitleTooGeneric = "FCE015"; + + // Category: Usage + public const string UnusedToExceptionResult = "FCE016"; + +} diff --git a/FirstClassErrors.Analyzers/DocumentationTitleTooGenericAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentationTitleTooGenericAnalyzer.cs new file mode 100644 index 0000000..5de6df6 --- /dev/null +++ b/FirstClassErrors.Analyzers/DocumentationTitleTooGenericAnalyzer.cs @@ -0,0 +1,55 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE015 — reports a WithTitle("...") whose literal title is one of a small denylist of empty phrases +/// (Error, Invalid value, Failure…) that describe nothing. Opt-in: disabled by default. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DocumentationTitleTooGenericAnalyzer : DiagnosticAnalyzer { + + private const string ErrorDescriptionStageMetadataName = "FirstClassErrors.IErrorDescriptionStage"; + private const string WithTitleMethodName = "WithTitle"; + + private static readonly ImmutableHashSet GenericTitles = ImmutableHashSet.Create( + StringComparer.OrdinalIgnoreCase, + "Error", "Invalid", "Invalid value", "Failure", "Failed", "Unknown", "Unknown error", "Bad", "Problem", "Something went wrong"); + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DocumentationTitleTooGeneric); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? descriptionStageType = context.Compilation.GetTypeByMetadataName(ErrorDescriptionStageMetadataName); + if (descriptionStageType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, descriptionStageType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol descriptionStageType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != WithTitleMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, descriptionStageType)) { return; } + if (invocation.Arguments.Length != 1) { return; } + + if (invocation.Arguments[0].Value.ConstantValue.Value is not string title) { return; } + if (!GenericTitles.Contains(title.Trim())) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentationTitleTooGeneric, invocation.Arguments[0].Value.Syntax.GetLocation(), title)); + } + +} diff --git a/FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs new file mode 100644 index 0000000..f559646 --- /dev/null +++ b/FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs @@ -0,0 +1,64 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE007 — reports a [DocumentedBy("X")] whose target method exists but cannot be used as a documentation +/// factory: it must be static, parameterless and return ErrorDocumentation. A missing target is FCE006's +/// concern, not this one. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DocumentedByInvalidSignatureAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DocumentedByInvalidSignature); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.DocumentedByAttribute is null) { return; } + + context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.Method); + } + + private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) { + IMethodSymbol method = (IMethodSymbol)context.Symbol; + + if (!SymbolFacts.TryGetDocumentedBy(method, symbols.DocumentedByAttribute!, out AttributeData? attribute, out string? targetName)) { return; } + if (string.IsNullOrEmpty(targetName)) { return; } + + ImmutableArray candidates = method.ContainingType + .GetMembers(targetName!) + .OfType() + .ToImmutableArray(); + + if (candidates.Length == 0) { return; } // not found → FCE006 + if (candidates.Any(candidate => IsValidDocumentationMethod(candidate, symbols.ErrorDocumentation))) { return; } + + Location location = attribute!.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation() + ?? method.Locations.FirstOrDefault() + ?? Location.None; + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentedByInvalidSignature, location, targetName)); + } + + private static bool IsValidDocumentationMethod(IMethodSymbol candidate, INamedTypeSymbol? errorDocumentationType) { + if (!candidate.IsStatic) { return false; } + if (candidate.Parameters.Length != 0) { return false; } + if (errorDocumentationType is null) { return true; } // cannot verify the return type; avoid a false positive + + return SymbolFacts.IsOrInheritsFrom(candidate.ReturnType, errorDocumentationType); + } + +} diff --git a/FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs new file mode 100644 index 0000000..28db9b5 --- /dev/null +++ b/FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs @@ -0,0 +1,53 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE006 — reports a [DocumentedBy("X")] whose target method name does not exist on the containing type. +/// The documentation reference is resolved by name at extraction time, so a typo fails silently. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DocumentedByTargetNotFoundAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DocumentedByTargetNotFound); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.DocumentedByAttribute is null) { return; } + + context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.Method); + } + + private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) { + IMethodSymbol method = (IMethodSymbol)context.Symbol; + + if (!SymbolFacts.TryGetDocumentedBy(method, symbols.DocumentedByAttribute!, out AttributeData? attribute, out string? targetName)) { return; } + if (string.IsNullOrEmpty(targetName)) { return; } + + bool exists = method.ContainingType + .GetMembers(targetName!) + .OfType() + .Any(); + if (exists) { return; } + + Location location = attribute!.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation() + ?? method.Locations.FirstOrDefault() + ?? Location.None; + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentedByTargetNotFound, location, targetName)); + } + +} diff --git a/FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs new file mode 100644 index 0000000..5ccc18d --- /dev/null +++ b/FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs @@ -0,0 +1,51 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE008 — reports a type that declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]. +/// Documentation extraction only scans types carrying [ProvidesErrorsFor], so every documented error on such +/// a type is silently ignored. Reported once per type. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DocumentedByWithoutProvidesErrorsForAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DocumentedByWithoutProvidesErrorsFor); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.DocumentedByAttribute is null || symbols.ProvidesErrorsForAttribute is null) { return; } + + context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.NamedType); + } + + private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) { + INamedTypeSymbol type = (INamedTypeSymbol)context.Symbol; + + if (type.TypeKind != TypeKind.Class) { return; } + if (SymbolFacts.HasAttribute(type, symbols.ProvidesErrorsForAttribute!)) { return; } + + bool hasDocumentedFactory = type.GetMembers() + .OfType() + .Any(method => SymbolFacts.HasAttribute(method, symbols.DocumentedByAttribute!)); + if (!hasDocumentedFactory) { return; } + + Location location = type.Locations.FirstOrDefault() ?? Location.None; + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentedByWithoutProvidesErrorsFor, location, type.Name)); + } + +} diff --git a/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs new file mode 100644 index 0000000..5bc4139 --- /dev/null +++ b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE011 — reports when more than one documented factory produces the same error code by referencing the same +/// ErrorCode field. Documentation extraction groups by code and keeps a single entry, so the others collapse +/// silently. This complements FCE001, which only sees duplicate ErrorCode.Create literals. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DuplicateDocumentedCodeAnalyzer : DiagnosticAnalyzer { + + private const string CreateMethodName = "Create"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DuplicateDocumentedCode); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Error is null || symbols.DocumentedByAttribute is null) { return; } + + ConcurrentDictionary> usagesByCodeField = new(SymbolEqualityComparer.Default); + + context.RegisterOperationBlockAction(blockContext => Collect(blockContext, symbols, usagesByCodeField)); + context.RegisterCompilationEndAction(endContext => Report(endContext, usagesByCodeField)); + } + + private static void Collect( + OperationBlockAnalysisContext context, + KnownSymbols symbols, + ConcurrentDictionary> usagesByCodeField) { + + if (context.OwningSymbol is not IMethodSymbol method) { return; } + if (!SymbolFacts.HasAttribute(method, symbols.DocumentedByAttribute!)) { return; } + + foreach (IOperation block in context.OperationBlocks) { + foreach (IOperation operation in OperationFacts.EnumerateOperations(block)) { + if (operation is not IInvocationOperation invocation) { continue; } + if (invocation.TargetMethod.Name != CreateMethodName) { continue; } + if (!SymbolFacts.IsOrInheritsFrom(invocation.TargetMethod.ContainingType, symbols.Error!)) { continue; } + if (invocation.Arguments.Length == 0) { continue; } + + if (TryGetCodeField(invocation.Arguments[0].Value, out ISymbol? codeField)) { + usagesByCodeField.GetOrAdd(codeField!, _ => new ConcurrentBag()) + .Add(method.Locations.FirstOrDefault() ?? Location.None); + } + + return; // the outermost error-factory Create identifies the produced code + } + } + } + + private static bool TryGetCodeField(IOperation codeArgument, out ISymbol? codeField) { + IOperation value = codeArgument is IConversionOperation conversion ? conversion.Operand : codeArgument; + + if (value is IFieldReferenceOperation fieldReference) { + codeField = fieldReference.Field; + + return true; + } + + codeField = null; + + return false; + } + + private static void Report( + CompilationAnalysisContext context, + ConcurrentDictionary> usagesByCodeField) { + + foreach (KeyValuePair> entry in usagesByCodeField) { + Location[] locations = entry.Value.ToArray(); + if (locations.Length < 2) { continue; } + + foreach (Location location in locations) { + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DuplicateDocumentedCode, location, entry.Key.Name)); + } + } + } + +} diff --git a/FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs b/FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs new file mode 100644 index 0000000..c344b00 --- /dev/null +++ b/FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE001 — reports the same error code created by more than one ErrorCode.Create("X") literal in the +/// compilation. ErrorCode.Create registers each code in a process-wide set and throws when a code is created +/// twice. Detection is per-compilation (cross-assembly duplicates still surface only at runtime) and limited to +/// literal codes — a non-literal code is FCE003's concern. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DuplicateErrorCodeAnalyzer : DiagnosticAnalyzer { + + private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode"; + private const string CreateMethodName = "Create"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DuplicateErrorCode); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName); + if (errorCodeType is null) { return; } + + // Per-compilation state; ErrorCode registers with ordinal comparison, so duplicates are case-sensitive. + ConcurrentDictionary> occurrences = new(StringComparer.Ordinal); + + context.RegisterOperationAction(operationContext => Collect(operationContext, errorCodeType, occurrences), OperationKind.Invocation); + context.RegisterCompilationEndAction(endContext => Report(endContext, occurrences)); + } + + private static void Collect( + OperationAnalysisContext context, + INamedTypeSymbol errorCodeType, + ConcurrentDictionary> occurrences) { + + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (!method.IsStatic || method.Name != CreateMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; } + if (invocation.Arguments.Length != 1) { return; } + + IOperation argument = invocation.Arguments[0].Value; + Optional constant = argument.ConstantValue; + + if (!constant.HasValue) { return; } // non-literal → FCE003 + if (constant.Value is not string code || string.IsNullOrWhiteSpace(code)) { return; } // empty → FCE002 + + occurrences.GetOrAdd(code, _ => new ConcurrentBag()).Add(argument.Syntax.GetLocation()); + } + + private static void Report( + CompilationAnalysisContext context, + ConcurrentDictionary> occurrences) { + + foreach (KeyValuePair> entry in occurrences) { + Location[] locations = entry.Value.ToArray(); + if (locations.Length < 2) { continue; } + + foreach (Location location in locations) { + IEnumerable others = locations.Where(other => !other.Equals(location)); + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DuplicateErrorCode, location, others, entry.Key)); + } + } + } + +} diff --git a/FirstClassErrors.Analyzers/EmptyErrorCodeAnalyzer.cs b/FirstClassErrors.Analyzers/EmptyErrorCodeAnalyzer.cs new file mode 100644 index 0000000..d90d3e9 --- /dev/null +++ b/FirstClassErrors.Analyzers/EmptyErrorCodeAnalyzer.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE002 — reports ErrorCode.Create("") (or a whitespace / null literal), which throws an +/// at runtime. Only literal arguments are inspected; a non-literal code is +/// out of scope here (see FCE003). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EmptyErrorCodeAnalyzer : DiagnosticAnalyzer { + + private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode"; + private const string CreateMethodName = "Create"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.EmptyErrorCode); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName); + if (errorCodeType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, errorCodeType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorCodeType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (!method.IsStatic || method.Name != CreateMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; } + if (invocation.Arguments.Length != 1) { return; } + + IOperation argument = invocation.Arguments[0].Value; + Optional constant = argument.ConstantValue; + + // A non-constant argument cannot be judged statically; FCE003 flags that separately. + if (!constant.HasValue) { return; } + if (constant.Value is string value && !string.IsNullOrWhiteSpace(value)) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.EmptyErrorCode, argument.Syntax.GetLocation())); + } + +} diff --git a/FirstClassErrors.Analyzers/EmptyExamplesAnalyzer.cs b/FirstClassErrors.Analyzers/EmptyExamplesAnalyzer.cs new file mode 100644 index 0000000..0a47c93 --- /dev/null +++ b/FirstClassErrors.Analyzers/EmptyExamplesAnalyzer.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE012 — reports the terminal WithExamples() call of the documentation DSL when it is given no example +/// factory. The call cannot be skipped (it produces the ErrorDocumentation), but it can be called empty, +/// yielding documentation that shows no realistic message. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EmptyExamplesAnalyzer : DiagnosticAnalyzer { + + private const string ErrorDocumentationMetadataName = "FirstClassErrors.ErrorDocumentation"; + private const string WithExamplesMethodName = "WithExamples"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.EmptyExamples); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorDocumentationType = context.Compilation.GetTypeByMetadataName(ErrorDocumentationMetadataName); + if (errorDocumentationType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, errorDocumentationType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorDocumentationType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != WithExamplesMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, errorDocumentationType)) { return; } + if (!IsEmptyParamArray(invocation)) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.EmptyExamples, invocation.Syntax.GetLocation())); + } + + private static bool IsEmptyParamArray(IInvocationOperation invocation) { + if (invocation.Arguments.Length != 1) { return false; } + + IArgumentOperation argument = invocation.Arguments[0]; + if (argument.ArgumentKind != ArgumentKind.ParamArray) { return false; } + if (argument.Value is not IArrayCreationOperation array) { return false; } + + if (array.Initializer is { } initializer) { return initializer.ElementValues.Length == 0; } + + return array.DimensionSizes.Length == 1 && array.DimensionSizes[0].ConstantValue is { HasValue: true, Value: 0 }; + } + +} diff --git a/FirstClassErrors.Analyzers/ErrorFactoryNotDocumentedAnalyzer.cs b/FirstClassErrors.Analyzers/ErrorFactoryNotDocumentedAnalyzer.cs new file mode 100644 index 0000000..267ff4a --- /dev/null +++ b/FirstClassErrors.Analyzers/ErrorFactoryNotDocumentedAnalyzer.cs @@ -0,0 +1,49 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE009 — reports a non-private static factory in a [ProvidesErrorsFor] type that returns an +/// Error but carries no [DocumentedBy]. Such an error is never added to the generated catalog. +/// Private methods are treated as helpers and left alone. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ErrorFactoryNotDocumentedAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.ErrorFactoryNotDocumented); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Error is null || symbols.ProvidesErrorsForAttribute is null || symbols.DocumentedByAttribute is null) { return; } + + context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.Method); + } + + private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) { + IMethodSymbol method = (IMethodSymbol)context.Symbol; + + if (!method.IsStatic || method.MethodKind != MethodKind.Ordinary) { return; } + if (method.DeclaredAccessibility == Accessibility.Private) { return; } + if (!SymbolFacts.IsOrInheritsFrom(method.ReturnType, symbols.Error!)) { return; } + if (!SymbolFacts.HasAttribute(method.ContainingType, symbols.ProvidesErrorsForAttribute!)) { return; } + if (SymbolFacts.HasAttribute(method, symbols.DocumentedByAttribute!)) { return; } + + Location location = method.Locations.FirstOrDefault() ?? Location.None; + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.ErrorFactoryNotDocumented, location, method.Name)); + } + +} diff --git a/FirstClassErrors.Analyzers/ExampleDoesNotCallDocumentedFactoryAnalyzer.cs b/FirstClassErrors.Analyzers/ExampleDoesNotCallDocumentedFactoryAnalyzer.cs new file mode 100644 index 0000000..a961356 --- /dev/null +++ b/FirstClassErrors.Analyzers/ExampleDoesNotCallDocumentedFactoryAnalyzer.cs @@ -0,0 +1,85 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE013 — reports an example passed to WithExamples(...) that does not invoke any factory of the type +/// declaring the documentation. Examples are meant to expose the documented error's real messages, so each should +/// build that error. Unrecognized example shapes are left alone to avoid false positives. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ExampleDoesNotCallDocumentedFactoryAnalyzer : DiagnosticAnalyzer { + + private const string ErrorDocumentationMetadataName = "FirstClassErrors.ErrorDocumentation"; + private const string WithExamplesMethodName = "WithExamples"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.ExampleDoesNotCallDocumentedFactory); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorDocumentationType = context.Compilation.GetTypeByMetadataName(ErrorDocumentationMetadataName); + if (errorDocumentationType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, errorDocumentationType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorDocumentationType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != WithExamplesMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, errorDocumentationType)) { return; } + + INamedTypeSymbol? documentingType = context.ContainingSymbol.ContainingType; + if (documentingType is null) { return; } + + foreach (IOperation example in GetExampleOperations(invocation)) { + if (!ExampleInvokesMemberOf(example, documentingType)) { + context.ReportDiagnostic(Diagnostic.Create(Descriptors.ExampleDoesNotCallDocumentedFactory, example.Syntax.GetLocation(), documentingType.Name)); + } + } + } + + private static IEnumerable GetExampleOperations(IInvocationOperation invocation) { + if (invocation.Arguments.Length != 1) { return Enumerable.Empty(); } + + IArgumentOperation argument = invocation.Arguments[0]; + if (argument.ArgumentKind != ArgumentKind.ParamArray) { return Enumerable.Empty(); } + if (argument.Value is not IArrayCreationOperation array || array.Initializer is null) { return Enumerable.Empty(); } + + return array.Initializer.ElementValues; + } + + private static bool ExampleInvokesMemberOf(IOperation example, INamedTypeSymbol documentingType) { + IOperation value = example; + if (value is IConversionOperation conversion) { value = conversion.Operand; } + if (value is IDelegateCreationOperation delegateCreation) { value = delegateCreation.Target; } + + if (value is IMethodReferenceOperation methodReference) { + return SymbolEqualityComparer.Default.Equals(methodReference.Method.ContainingType, documentingType); + } + + if (value is IAnonymousFunctionOperation lambda) { + return OperationFacts.EnumerateOperations(lambda.Body) + .OfType() + .Any(call => SymbolEqualityComparer.Default.Equals(call.TargetMethod.ContainingType, documentingType)); + } + + // Unrecognized example shape → do not flag, to avoid false positives. + return true; + } + +} diff --git a/FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj b/FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj new file mode 100644 index 0000000..da04f25 --- /dev/null +++ b/FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj @@ -0,0 +1,32 @@ + + + + + netstandard2.0 + enable + enable + latest + + + true + + + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/FirstClassErrors.Analyzers/HelpLinks.cs b/FirstClassErrors.Analyzers/HelpLinks.cs new file mode 100644 index 0000000..651f3a4 --- /dev/null +++ b/FirstClassErrors.Analyzers/HelpLinks.cs @@ -0,0 +1,15 @@ +namespace FirstClassErrors.Analyzers; + +/// +/// Builds the documentation URL surfaced by each diagnostic (the "help link" in the IDE). Per-rule pages are added +/// under doc/analyzers/ in a later phase. +/// +internal static class HelpLinks { + + private const string Base = "https://github.com/Reefact/first-class-errors/blob/main/doc/analyzers"; + + public static string For(string diagnosticId) { + return $"{Base}/{diagnosticId}.en.md"; + } + +} diff --git a/FirstClassErrors.Analyzers/InvalidErrorCodeFormatAnalyzer.cs b/FirstClassErrors.Analyzers/InvalidErrorCodeFormatAnalyzer.cs new file mode 100644 index 0000000..9d33073 --- /dev/null +++ b/FirstClassErrors.Analyzers/InvalidErrorCodeFormatAnalyzer.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE004 — reports a literal ErrorCode.Create("...") whose code does not follow the UPPER_SNAKE_CASE +/// convention (e.g. MONEY_TRANSFER_INVALID). Convention check, opt-in: disabled by default. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class InvalidErrorCodeFormatAnalyzer : DiagnosticAnalyzer { + + private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode"; + private const string CreateMethodName = "Create"; + + private static readonly Regex UpperSnakeCase = new("^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$", RegexOptions.CultureInvariant); + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.InvalidErrorCodeFormat); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName); + if (errorCodeType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, errorCodeType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorCodeType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (!method.IsStatic || method.Name != CreateMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; } + if (invocation.Arguments.Length != 1) { return; } + + IOperation argument = invocation.Arguments[0].Value; + if (argument.ConstantValue.Value is not string code) { return; } // non-literal → FCE003 + if (string.IsNullOrWhiteSpace(code)) { return; } // empty → FCE002 + if (UpperSnakeCase.IsMatch(code)) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.InvalidErrorCodeFormat, argument.Syntax.GetLocation(), code)); + } + +} diff --git a/FirstClassErrors.Analyzers/KnownSymbols.cs b/FirstClassErrors.Analyzers/KnownSymbols.cs new file mode 100644 index 0000000..afcb951 --- /dev/null +++ b/FirstClassErrors.Analyzers/KnownSymbols.cs @@ -0,0 +1,33 @@ +using Microsoft.CodeAnalysis; + +namespace FirstClassErrors.Analyzers; + +/// +/// Resolves the FirstClassErrors types an analyzer matches against, by metadata name. Analyzers never reference the +/// core assembly directly; a null field simply means the core is not part of the analyzed compilation, in which +/// case the analyzer stays silent. +/// +internal sealed class KnownSymbols { + + public const string ErrorMetadataName = "FirstClassErrors.Error"; + public const string DocumentedByAttributeMetadataName = "FirstClassErrors.DocumentedByAttribute"; + public const string ProvidesErrorsForAttributeMetadataName = "FirstClassErrors.ProvidesErrorsForAttribute"; + public const string ErrorDocumentationMetadataName = "FirstClassErrors.ErrorDocumentation"; + + private KnownSymbols(Compilation compilation) { + Error = compilation.GetTypeByMetadataName(ErrorMetadataName); + DocumentedByAttribute = compilation.GetTypeByMetadataName(DocumentedByAttributeMetadataName); + ProvidesErrorsForAttribute = compilation.GetTypeByMetadataName(ProvidesErrorsForAttributeMetadataName); + ErrorDocumentation = compilation.GetTypeByMetadataName(ErrorDocumentationMetadataName); + } + + public INamedTypeSymbol? Error { get; } + public INamedTypeSymbol? DocumentedByAttribute { get; } + public INamedTypeSymbol? ProvidesErrorsForAttribute { get; } + public INamedTypeSymbol? ErrorDocumentation { get; } + + public static KnownSymbols From(Compilation compilation) { + return new KnownSymbols(compilation); + } + +} diff --git a/FirstClassErrors.Analyzers/MultipleFactoriesShareDocumentationAnalyzer.cs b/FirstClassErrors.Analyzers/MultipleFactoriesShareDocumentationAnalyzer.cs new file mode 100644 index 0000000..193b812 --- /dev/null +++ b/FirstClassErrors.Analyzers/MultipleFactoriesShareDocumentationAnalyzer.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE010 — reports factories in the same type whose [DocumentedBy] point at the same documentation method. +/// One documentation method describes one error, so sharing it means at least one error is mis-documented. Every +/// sharing factory is flagged. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MultipleFactoriesShareDocumentationAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.MultipleFactoriesShareDocumentation); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.DocumentedByAttribute is null) { return; } + + context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.NamedType); + } + + private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) { + INamedTypeSymbol type = (INamedTypeSymbol)context.Symbol; + if (type.TypeKind != TypeKind.Class) { return; } + + Dictionary> referencesByTarget = new(StringComparer.Ordinal); + + foreach (ISymbol member in type.GetMembers()) { + if (member is not IMethodSymbol method) { continue; } + if (!SymbolFacts.TryGetDocumentedBy(method, symbols.DocumentedByAttribute!, out AttributeData? attribute, out string? targetName)) { continue; } + if (string.IsNullOrEmpty(targetName)) { continue; } + + if (!referencesByTarget.TryGetValue(targetName!, out List? references)) { + references = new List(); + referencesByTarget[targetName!] = references; + } + + references.Add(attribute!); + } + + foreach (KeyValuePair> entry in referencesByTarget) { + if (entry.Value.Count < 2) { continue; } + + foreach (AttributeData attribute in entry.Value) { + Location location = attribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation() + ?? type.Locations.FirstOrDefault() + ?? Location.None; + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.MultipleFactoriesShareDocumentation, location, entry.Key)); + } + } + } + +} diff --git a/FirstClassErrors.Analyzers/NonLiteralErrorCodeAnalyzer.cs b/FirstClassErrors.Analyzers/NonLiteralErrorCodeAnalyzer.cs new file mode 100644 index 0000000..6d7b978 --- /dev/null +++ b/FirstClassErrors.Analyzers/NonLiteralErrorCodeAnalyzer.cs @@ -0,0 +1,51 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE003 — reports ErrorCode.Create(x) where the argument is not a compile-time constant. Such a code is a +/// blind spot for FCE001 (duplicate detection). Opt-in: disabled by default. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class NonLiteralErrorCodeAnalyzer : DiagnosticAnalyzer { + + private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode"; + private const string CreateMethodName = "Create"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.NonLiteralErrorCode); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName); + if (errorCodeType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, errorCodeType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorCodeType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (!method.IsStatic || method.Name != CreateMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; } + if (invocation.Arguments.Length != 1) { return; } + + IOperation argument = invocation.Arguments[0].Value; + if (argument.ConstantValue.HasValue) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.NonLiteralErrorCode, argument.Syntax.GetLocation())); + } + +} diff --git a/FirstClassErrors.Analyzers/OperationFacts.cs b/FirstClassErrors.Analyzers/OperationFacts.cs new file mode 100644 index 0000000..30a529f --- /dev/null +++ b/FirstClassErrors.Analyzers/OperationFacts.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; + +namespace FirstClassErrors.Analyzers; + +/// +/// Small operation-tree helpers shared by analyzers that walk method or lambda bodies. +/// +internal static class OperationFacts { + + /// + /// Enumerates an operation subtree, root first, guaranteeing every ancestor is yielded before its descendants. + /// + public static IEnumerable EnumerateOperations(IOperation root) { + Stack pending = new(); + pending.Push(root); + + while (pending.Count > 0) { + IOperation current = pending.Pop(); + yield return current; + + foreach (IOperation child in current.ChildOperations) { + pending.Push(child); + } + } + } + +} diff --git a/FirstClassErrors.Analyzers/ShortMessageSameAsDetailedMessageAnalyzer.cs b/FirstClassErrors.Analyzers/ShortMessageSameAsDetailedMessageAnalyzer.cs new file mode 100644 index 0000000..d553ce0 --- /dev/null +++ b/FirstClassErrors.Analyzers/ShortMessageSameAsDetailedMessageAnalyzer.cs @@ -0,0 +1,53 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE014 — reports WithPublicMessage(short, detailed) where the two literal messages are identical. The +/// short message is a public summary and the detailed one an optional public detail; making them equal usually +/// signals a copy-paste. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ShortMessageSameAsDetailedMessageAnalyzer : DiagnosticAnalyzer { + + private const string PublicMessageStageMetadataName = "FirstClassErrors.PublicMessageStage`1"; + private const string WithPublicMessageMethodName = "WithPublicMessage"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.ShortMessageSameAsDetailedMessage); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? publicMessageStageType = context.Compilation.GetTypeByMetadataName(PublicMessageStageMetadataName); + if (publicMessageStageType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, publicMessageStageType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol publicMessageStageType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != WithPublicMessageMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType.OriginalDefinition, publicMessageStageType)) { return; } + if (invocation.Arguments.Length != 2) { return; } + + if (invocation.Arguments[0].Value.ConstantValue.Value is not string shortMessage) { return; } + if (invocation.Arguments[1].Value.ConstantValue.Value is not string detailedMessage) { return; } + if (!string.Equals(shortMessage, detailedMessage, StringComparison.Ordinal)) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.ShortMessageSameAsDetailedMessage, invocation.Syntax.GetLocation())); + } + +} diff --git a/FirstClassErrors.Analyzers/SymbolFacts.cs b/FirstClassErrors.Analyzers/SymbolFacts.cs new file mode 100644 index 0000000..f7df652 --- /dev/null +++ b/FirstClassErrors.Analyzers/SymbolFacts.cs @@ -0,0 +1,57 @@ +using Microsoft.CodeAnalysis; + +namespace FirstClassErrors.Analyzers; + +/// +/// Small symbol-inspection helpers shared by the documentation-wiring analyzers. +/// +internal static class SymbolFacts { + + /// + /// Finds the [DocumentedBy] attribute on and the documentation method name it + /// references (the single string constructor argument). + /// + public static bool TryGetDocumentedBy( + IMethodSymbol method, + INamedTypeSymbol documentedByAttributeType, + out AttributeData? attribute, + out string? targetMethodName) { + + foreach (AttributeData candidate in method.GetAttributes()) { + if (SymbolEqualityComparer.Default.Equals(candidate.AttributeClass, documentedByAttributeType)) { + attribute = candidate; + targetMethodName = candidate.ConstructorArguments.Length == 1 + ? candidate.ConstructorArguments[0].Value as string + : null; + + return true; + } + } + + attribute = null; + targetMethodName = null; + + return false; + } + + public static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType) { + foreach (AttributeData attribute in symbol.GetAttributes()) { + if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)) { + return true; + } + } + + return false; + } + + public static bool IsOrInheritsFrom(ITypeSymbol type, INamedTypeSymbol target) { + for (ITypeSymbol? current = type; current is not null; current = current.BaseType) { + if (SymbolEqualityComparer.Default.Equals(current, target)) { + return true; + } + } + + return false; + } + +} diff --git a/FirstClassErrors.Analyzers/TooGenericErrorCodeAnalyzer.cs b/FirstClassErrors.Analyzers/TooGenericErrorCodeAnalyzer.cs new file mode 100644 index 0000000..7c1ff87 --- /dev/null +++ b/FirstClassErrors.Analyzers/TooGenericErrorCodeAnalyzer.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE005 — reports a literal error code drawn from a small denylist of catch-all words (ERROR, INVALID, FAILED…) +/// that carry no diagnostic value. Opt-in: disabled by default. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TooGenericErrorCodeAnalyzer : DiagnosticAnalyzer { + + private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode"; + private const string CreateMethodName = "Create"; + + private static readonly ImmutableHashSet GenericCodes = ImmutableHashSet.Create( + StringComparer.OrdinalIgnoreCase, + "ERROR", "INVALID", "FAILED", "FAILURE", "UNKNOWN", "EXCEPTION", "BAD", "WRONG", "GENERIC", "PROBLEM"); + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.TooGenericErrorCode); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName); + if (errorCodeType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, errorCodeType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorCodeType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (!method.IsStatic || method.Name != CreateMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; } + if (invocation.Arguments.Length != 1) { return; } + + IOperation argument = invocation.Arguments[0].Value; + if (argument.ConstantValue.Value is not string code) { return; } + if (!GenericCodes.Contains(code.Trim())) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.TooGenericErrorCode, argument.Syntax.GetLocation(), code)); + } + +} diff --git a/FirstClassErrors.Analyzers/UnusedToExceptionResultAnalyzer.cs b/FirstClassErrors.Analyzers/UnusedToExceptionResultAnalyzer.cs new file mode 100644 index 0000000..49d20d6 --- /dev/null +++ b/FirstClassErrors.Analyzers/UnusedToExceptionResultAnalyzer.cs @@ -0,0 +1,51 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace FirstClassErrors.Analyzers; + +/// +/// FCE016 — reports a call to Error.ToException() whose result is discarded (the call stands alone as a +/// statement). ToException() only builds the exception; without a throw (or capturing the result) +/// nothing happens. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnusedToExceptionResultAnalyzer : DiagnosticAnalyzer { + + private const string ErrorMetadataName = "FirstClassErrors.Error"; + private const string ToExceptionMethodName = "ToException"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.UnusedToExceptionResult); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + INamedTypeSymbol? errorType = context.Compilation.GetTypeByMetadataName(ErrorMetadataName); + if (errorType is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, errorType), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorType) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != ToExceptionMethodName || method.Parameters.Length != 0) { return; } + if (!SymbolFacts.IsOrInheritsFrom(method.ContainingType, errorType)) { return; } + + // The result is used unless the invocation stands alone as an expression statement. + if (invocation.Parent is not IExpressionStatementOperation) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.UnusedToExceptionResult, invocation.Syntax.GetLocation())); + } + +} diff --git a/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj b/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj index 4fe1172..4359a64 100644 --- a/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj +++ b/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj @@ -12,6 +12,11 @@ + + \ No newline at end of file diff --git a/FirstClassErrors.sln b/FirstClassErrors.sln index df6ffd9..96795f8 100644 --- a/FirstClassErrors.sln +++ b/FirstClassErrors.sln @@ -20,6 +20,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.GenDoc.Wor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.GenDoc.UnitTests", "FirstClassErrors.GenDoc.UnitTests\FirstClassErrors.GenDoc.UnitTests.csproj", "{D8B4E1F6-2C7A-4F93-8B05-1E6D9A3C4B72}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.Analyzers", "FirstClassErrors.Analyzers\FirstClassErrors.Analyzers.csproj", "{47052422-15BC-482A-8FC0-65F9AAAB0291}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.Analyzers.UnitTests", "FirstClassErrors.Analyzers.UnitTests\FirstClassErrors.Analyzers.UnitTests.csproj", "{2BE1F2DB-4A9E-4616-89BA-9A597B721217}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C331E842-DDA5-408B-94CB-D28F1BAFF920}" ProjectSection(SolutionItems) = preProject .gitignore = .gitignore @@ -61,6 +65,14 @@ Global {D8B4E1F6-2C7A-4F93-8B05-1E6D9A3C4B72}.Debug|Any CPU.Build.0 = Debug|Any CPU {D8B4E1F6-2C7A-4F93-8B05-1E6D9A3C4B72}.Release|Any CPU.ActiveCfg = Release|Any CPU {D8B4E1F6-2C7A-4F93-8B05-1E6D9A3C4B72}.Release|Any CPU.Build.0 = Release|Any CPU + {47052422-15BC-482A-8FC0-65F9AAAB0291}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47052422-15BC-482A-8FC0-65F9AAAB0291}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47052422-15BC-482A-8FC0-65F9AAAB0291}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47052422-15BC-482A-8FC0-65F9AAAB0291}.Release|Any CPU.Build.0 = Release|Any CPU + {2BE1F2DB-4A9E-4616-89BA-9A597B721217}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BE1F2DB-4A9E-4616-89BA-9A597B721217}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BE1F2DB-4A9E-4616-89BA-9A597B721217}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BE1F2DB-4A9E-4616-89BA-9A597B721217}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,6 +85,8 @@ Global {2106AE5F-8407-465B-AB0C-486127A53742} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {032D7F17-DBF8-49D6-86A8-E751ADB33D53} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {D8B4E1F6-2C7A-4F93-8B05-1E6D9A3C4B72} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB} + {47052422-15BC-482A-8FC0-65F9AAAB0291} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {2BE1F2DB-4A9E-4616-89BA-9A597B721217} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4988972E-3E0D-4F48-8656-0E67ECE994BF} diff --git a/FirstClassErrors/FirstClassErrors.csproj b/FirstClassErrors/FirstClassErrors.csproj index 32bb36f..2fc8343 100644 --- a/FirstClassErrors/FirstClassErrors.csproj +++ b/FirstClassErrors/FirstClassErrors.csproj @@ -55,4 +55,25 @@ + + + + + + + $(TargetsForTfmSpecificContentInPackage);_AddAnalyzerToPackage + + + + + + analyzers/dotnet/cs + + + + \ No newline at end of file diff --git a/README.md b/README.md index c34c772..d37bda6 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,12 @@ FirstClassErrors is especially useful if: * you want documentation that doesn’t drift from code * you design with domain-driven thinking +## 🔍 Analyzers + +FirstClassErrors ships with a set of Roslyn analyzers (rule ids `FCExxx`) **bundled in the NuGet package** — reference the package and they run at build time, no extra install. They catch, before you run anything, the mistakes the runtime or the documentation pipeline would otherwise surface late or silently: duplicate error codes, `[DocumentedBy]` references that don't resolve, documented errors that never reach the catalog, and more. + +See the [analyzer rules reference](doc/analyzers/README.md). + ## 📚 Next steps See the full documentation: @@ -210,4 +216,5 @@ See the full documentation: - [Writing a custom renderer](doc/WritingACustomRenderer.en.md) - [Internationalization](doc/Internationalization.en.md) - [Comparison with error-handling libraries](doc/ComparisonWithOtherLibraries.en.md) +- [Analyzer rules (FCExxx)](doc/analyzers/README.md) - [FAQ](doc/FAQ.en.md) diff --git a/doc/README.fr.md b/doc/README.fr.md index 4991871..504706e 100644 --- a/doc/README.fr.md +++ b/doc/README.fr.md @@ -193,6 +193,12 @@ FirstClassErrors est particulièrement utile si : * vous souhaitez une documentation qui ne dérive pas du code * vous concevez avec une approche orientée domaine +## 🔍 Analyseurs + +FirstClassErrors est livré avec un ensemble d’analyseurs Roslyn (identifiants de règle `FCExxx`) **inclus dans le package NuGet** — référencez le package et ils s’exécutent au build, sans installation supplémentaire. Ils détectent, avant toute exécution, les erreurs que le runtime ou le pipeline de documentation ne feraient sinon apparaître que tard, voire silencieusement : codes d’erreur dupliqués, références `[DocumentedBy]` qui ne résolvent pas, erreurs documentées qui n’atteignent jamais le catalogue, et plus encore. + +Voir la [référence des règles d’analyse](analyzers/README.fr.md). + ## 📚 Étapes suivantes Consultez la documentation complète : @@ -210,4 +216,5 @@ Consultez la documentation complète : - [Écrire son propre renderer](WritingACustomRenderer.fr.md) - [Internationalisation](Internationalisation.fr.md) - [Comparaison avec les librairies de gestion d’erreurs](ComparisonWithOtherLibraries.fr.md) +- [Règles d’analyse (FCExxx)](analyzers/README.fr.md) - [FAQ](FAQ.fr.md) diff --git a/doc/analyzers/FCE001.en.md b/doc/analyzers/FCE001.en.md new file mode 100644 index 0000000..65c1196 --- /dev/null +++ b/doc/analyzers/FCE001.en.md @@ -0,0 +1,33 @@ +# FCE001: DuplicateErrorCode + +| | | +|---|---| +| **Category** | Error codes (`FirstClassErrors.ErrorCodes`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +The same literal error code is created by more than one `ErrorCode.Create("...")` in the compilation. `ErrorCode.Create` registers every code in a process-wide set and throws an `InvalidOperationException` the second time a code is registered; this rule turns that runtime failure into a build error and lights up every site that produces the code. + +## Noncompliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +public static readonly ErrorCode B = ErrorCode.Create("MONEY_TRANSFER_INVALID"); // FCE001 +``` + +## Compliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +public static readonly ErrorCode B = ErrorCode.Create("MONEY_TRANSFER_AMOUNT_NOT_POSITIVE"); +``` + +## Details + +Detection is **per compilation** and covers only **literal** codes. Two identical codes in different assemblies, or a code built at run time, still surface only at run time (see [FCE003](FCE003.en.md)). This is a compilation-end diagnostic: it appears at build / full-solution analysis, not necessarily as you type in a single file. + +**Related:** [FCE002](FCE002.en.md), [FCE003](FCE003.en.md), [FCE011](FCE011.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE001.fr.md b/doc/analyzers/FCE001.fr.md new file mode 100644 index 0000000..b55fb8d --- /dev/null +++ b/doc/analyzers/FCE001.fr.md @@ -0,0 +1,33 @@ +# FCE001: DuplicateErrorCode + +| | | +|---|---| +| **Catégorie** | Codes d'erreur (`FirstClassErrors.ErrorCodes`) | +| **Sévérité** | 🔴 Error | +| **Activée par défaut** | Oui | + +Le même code d'erreur littéral est créé par plus d'un `ErrorCode.Create("...")` dans la compilation. `ErrorCode.Create` enregistre chaque code dans un ensemble à l'échelle du processus et lève une `InvalidOperationException` au second enregistrement d'un même code ; cette règle transforme cet échec à l'exécution en erreur de build et signale chaque site qui produit le code. + +## Non conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +public static readonly ErrorCode B = ErrorCode.Create("MONEY_TRANSFER_INVALID"); // FCE001 +``` + +## Conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +public static readonly ErrorCode B = ErrorCode.Create("MONEY_TRANSFER_AMOUNT_NOT_POSITIVE"); +``` + +## Détails + +La détection est **par compilation** et ne couvre que les codes **littéraux**. Deux codes identiques dans des assemblies différentes, ou un code construit à l'exécution, n'apparaissent qu'à l'exécution (voir [FCE003](FCE003.fr.md)). C'est un diagnostic de fin de compilation : il apparaît au build / à l'analyse de la solution entière, pas forcément à la frappe dans un seul fichier. + +**Voir aussi:** [FCE002](FCE002.fr.md), [FCE003](FCE003.fr.md), [FCE011](FCE011.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE002.en.md b/doc/analyzers/FCE002.en.md new file mode 100644 index 0000000..7f91782 --- /dev/null +++ b/doc/analyzers/FCE002.en.md @@ -0,0 +1,31 @@ +# FCE002: EmptyErrorCode + +| | | +|---|---| +| **Category** | Error codes (`FirstClassErrors.ErrorCodes`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +`ErrorCode.Create` is called with an empty, whitespace, or `null` literal. That throws an `ArgumentException` at run time; this rule catches it at build time. + +## Noncompliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create(""); // FCE002 +``` + +## Compliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +``` + +## Details + +Only literal arguments are inspected. A code computed at run time is out of scope (see [FCE003](FCE003.en.md)). + +**Related:** [FCE001](FCE001.en.md), [FCE003](FCE003.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE002.fr.md b/doc/analyzers/FCE002.fr.md new file mode 100644 index 0000000..6d5e9c4 --- /dev/null +++ b/doc/analyzers/FCE002.fr.md @@ -0,0 +1,31 @@ +# FCE002: EmptyErrorCode + +| | | +|---|---| +| **Catégorie** | Codes d'erreur (`FirstClassErrors.ErrorCodes`) | +| **Sévérité** | 🔴 Error | +| **Activée par défaut** | Oui | + +`ErrorCode.Create` est appelé avec un littéral vide, composé d'espaces, ou `null`. Cela lève une `ArgumentException` à l'exécution ; cette règle l'attrape au build. + +## Non conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create(""); // FCE002 +``` + +## Conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +``` + +## Détails + +Seuls les arguments littéraux sont inspectés. Un code calculé à l'exécution est hors de portée (voir [FCE003](FCE003.fr.md)). + +**Voir aussi:** [FCE001](FCE001.fr.md), [FCE003](FCE003.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE003.en.md b/doc/analyzers/FCE003.en.md new file mode 100644 index 0000000..b739ae9 --- /dev/null +++ b/doc/analyzers/FCE003.en.md @@ -0,0 +1,36 @@ +# FCE003: NonLiteralErrorCode + +| | | +|---|---| +| **Category** | Error codes (`FirstClassErrors.ErrorCodes`) | +| **Severity** | 🔵 Info | +| **Enabled by default** | No (opt-in) | + +`ErrorCode.Create` is called with an argument that is not a compile-time constant. Such a code is a blind spot for duplicate detection ([FCE001](FCE001.en.md)) and format checks. Enable this rule if your team wants every error code to stay literal. + +## Noncompliant + +```csharp +private static string Build() => "DYNAMIC"; +public static readonly ErrorCode A = ErrorCode.Create(Build()); // FCE003 +``` + +## Compliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +``` + +## Enabling this rule + +This rule is disabled by default. Turn it on in `.editorconfig`: + +```ini +dotnet_diagnostic.FCE003.severity = suggestion # or warning / error +``` + +**Related:** [FCE001](FCE001.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE003.fr.md b/doc/analyzers/FCE003.fr.md new file mode 100644 index 0000000..7f06f1b --- /dev/null +++ b/doc/analyzers/FCE003.fr.md @@ -0,0 +1,36 @@ +# FCE003: NonLiteralErrorCode + +| | | +|---|---| +| **Catégorie** | Codes d'erreur (`FirstClassErrors.ErrorCodes`) | +| **Sévérité** | 🔵 Info | +| **Activée par défaut** | Non (opt-in) | + +`ErrorCode.Create` est appelé avec un argument qui n'est pas une constante de compilation. Un tel code est un angle mort pour la détection de doublons ([FCE001](FCE001.fr.md)) et les vérifications de format. Activez cette règle si votre équipe veut que chaque code d'erreur reste littéral. + +## Non conforme + +```csharp +private static string Build() => "DYNAMIC"; +public static readonly ErrorCode A = ErrorCode.Create(Build()); // FCE003 +``` + +## Conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +``` + +## Activer cette règle + +Cette règle est désactivée par défaut. Activez-la dans `.editorconfig` : + +```ini +dotnet_diagnostic.FCE003.severity = suggestion # ou warning / error +``` + +**Voir aussi:** [FCE001](FCE001.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE004.en.md b/doc/analyzers/FCE004.en.md new file mode 100644 index 0000000..2c9eae3 --- /dev/null +++ b/doc/analyzers/FCE004.en.md @@ -0,0 +1,39 @@ +# FCE004: InvalidErrorCodeFormat + +| | | +|---|---| +| **Category** | Error codes (`FirstClassErrors.ErrorCodes`) | +| **Severity** | 🔵 Info | +| **Enabled by default** | No (opt-in) | + +A literal error code does not follow the `UPPER_SNAKE_CASE` convention. A consistent format keeps catalogs and logs scannable. + +## Noncompliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("moneyTransferInvalid"); // FCE004 +``` + +## Compliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +``` + +## Details + +The expected pattern is `^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$`. Empty codes are handled by [FCE002](FCE002.en.md) and non-literal codes by [FCE003](FCE003.en.md). + +## Enabling this rule + +This rule is disabled by default. Turn it on in `.editorconfig`: + +```ini +dotnet_diagnostic.FCE004.severity = suggestion # or warning / error +``` + +**Related:** [FCE002](FCE002.en.md), [FCE005](FCE005.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE004.fr.md b/doc/analyzers/FCE004.fr.md new file mode 100644 index 0000000..60a6d4a --- /dev/null +++ b/doc/analyzers/FCE004.fr.md @@ -0,0 +1,39 @@ +# FCE004: InvalidErrorCodeFormat + +| | | +|---|---| +| **Catégorie** | Codes d'erreur (`FirstClassErrors.ErrorCodes`) | +| **Sévérité** | 🔵 Info | +| **Activée par défaut** | Non (opt-in) | + +Un code d'erreur littéral ne respecte pas la convention `UPPER_SNAKE_CASE`. Un format cohérent garde les catalogues et les logs lisibles. + +## Non conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("moneyTransferInvalid"); // FCE004 +``` + +## Conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID"); +``` + +## Détails + +Le motif attendu est `^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$`. Les codes vides sont traités par [FCE002](FCE002.fr.md) et les codes non littéraux par [FCE003](FCE003.fr.md). + +## Activer cette règle + +Cette règle est désactivée par défaut. Activez-la dans `.editorconfig` : + +```ini +dotnet_diagnostic.FCE004.severity = suggestion # ou warning / error +``` + +**Voir aussi:** [FCE002](FCE002.fr.md), [FCE005](FCE005.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE005.en.md b/doc/analyzers/FCE005.en.md new file mode 100644 index 0000000..08db691 --- /dev/null +++ b/doc/analyzers/FCE005.en.md @@ -0,0 +1,35 @@ +# FCE005: TooGenericErrorCode + +| | | +|---|---| +| **Category** | Error codes (`FirstClassErrors.ErrorCodes`) | +| **Severity** | 🔵 Info | +| **Enabled by default** | No (opt-in) | + +A literal error code is one of a small set of catch-all words (`ERROR`, `INVALID`, `FAILED`, ...) that carry no diagnostic value. Prefer a code that names the specific condition. + +## Noncompliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("INVALID"); // FCE005 +``` + +## Compliant + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_AMOUNT_NOT_POSITIVE"); +``` + +## Enabling this rule + +This rule is disabled by default. Turn it on in `.editorconfig`: + +```ini +dotnet_diagnostic.FCE005.severity = suggestion # or warning / error +``` + +**Related:** [FCE004](FCE004.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE005.fr.md b/doc/analyzers/FCE005.fr.md new file mode 100644 index 0000000..bb9bd77 --- /dev/null +++ b/doc/analyzers/FCE005.fr.md @@ -0,0 +1,35 @@ +# FCE005: TooGenericErrorCode + +| | | +|---|---| +| **Catégorie** | Codes d'erreur (`FirstClassErrors.ErrorCodes`) | +| **Sévérité** | 🔵 Info | +| **Activée par défaut** | Non (opt-in) | + +Un code d'erreur littéral fait partie d'un petit ensemble de mots fourre-tout (`ERROR`, `INVALID`, `FAILED`, …) sans valeur diagnostique. Préférez un code qui nomme la condition précise. + +## Non conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("INVALID"); // FCE005 +``` + +## Conforme + +```csharp +public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_AMOUNT_NOT_POSITIVE"); +``` + +## Activer cette règle + +Cette règle est désactivée par défaut. Activez-la dans `.editorconfig` : + +```ini +dotnet_diagnostic.FCE005.severity = suggestion # ou warning / error +``` + +**Voir aussi:** [FCE004](FCE004.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE006.en.md b/doc/analyzers/FCE006.en.md new file mode 100644 index 0000000..f57f9c2 --- /dev/null +++ b/doc/analyzers/FCE006.en.md @@ -0,0 +1,38 @@ +# FCE006: DocumentedByTargetNotFound + +| | | +|---|---| +| **Category** | Documentation wiring (`FirstClassErrors.DocumentationWiring`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +A `[DocumentedBy("...")]` names a documentation method that does not exist on the containing type. The reference is resolved by name at extraction time, so a typo is silently skipped and the error goes undocumented. + +## Noncompliant + +```csharp +[ProvidesErrorsFor(nameof(Temperature))] +public static class InvalidTemperatureError { + [DocumentedBy("BelowAbsoluteZeroDocumentaion")] // FCE006 (typo) + internal static DomainError BelowAbsoluteZero() => /* ... */; + + private static ErrorDocumentation BelowAbsoluteZeroDocumentation() => /* ... */; +} +``` + +## Compliant + +```csharp +[DocumentedBy(nameof(BelowAbsoluteZeroDocumentation))] +internal static DomainError BelowAbsoluteZero() => /* ... */; +``` + +## Details + +Prefer `nameof(...)` over a string literal so the compiler itself catches a missing target. + +**Related:** [FCE007](FCE007.en.md), [FCE008](FCE008.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE006.fr.md b/doc/analyzers/FCE006.fr.md new file mode 100644 index 0000000..e4ad7f5 --- /dev/null +++ b/doc/analyzers/FCE006.fr.md @@ -0,0 +1,38 @@ +# FCE006: DocumentedByTargetNotFound + +| | | +|---|---| +| **Catégorie** | Câblage de la documentation (`FirstClassErrors.DocumentationWiring`) | +| **Sévérité** | 🔴 Error | +| **Activée par défaut** | Oui | + +Un `[DocumentedBy("...")]` désigne une méthode de documentation qui n'existe pas sur le type contenant. La référence est résolue par nom au moment de l'extraction : une faute de frappe est silencieusement ignorée et l'erreur reste non documentée. + +## Non conforme + +```csharp +[ProvidesErrorsFor(nameof(Temperature))] +public static class InvalidTemperatureError { + [DocumentedBy("BelowAbsoluteZeroDocumentaion")] // FCE006 (typo) + internal static DomainError BelowAbsoluteZero() => /* ... */; + + private static ErrorDocumentation BelowAbsoluteZeroDocumentation() => /* ... */; +} +``` + +## Conforme + +```csharp +[DocumentedBy(nameof(BelowAbsoluteZeroDocumentation))] +internal static DomainError BelowAbsoluteZero() => /* ... */; +``` + +## Détails + +Préférez `nameof(...)` à un littéral chaîne pour que le compilateur lui-même détecte une cible manquante. + +**Voir aussi:** [FCE007](FCE007.fr.md), [FCE008](FCE008.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE007.en.md b/doc/analyzers/FCE007.en.md new file mode 100644 index 0000000..2aa838f --- /dev/null +++ b/doc/analyzers/FCE007.en.md @@ -0,0 +1,35 @@ +# FCE007: DocumentedByInvalidSignature + +| | | +|---|---| +| **Category** | Documentation wiring (`FirstClassErrors.DocumentationWiring`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +The method referenced by `[DocumentedBy]` exists but cannot be used as a documentation factory. A documentation method must be **static**, **parameterless**, and return **`ErrorDocumentation`**. + +## Noncompliant + +```csharp +[DocumentedBy(nameof(Doc))] +internal static DomainError Boom() => /* ... */; + +private static string Doc() => "not a documentation"; // FCE007: wrong return type +``` + +## Compliant + +```csharp +private static ErrorDocumentation Doc() => + DescribeError.WithTitle("...").WithDescription("...").WithoutRule().WithoutDiagnostic().WithExamples(); +``` + +## Details + +A missing target is not this rule's concern; see [FCE006](FCE006.en.md). + +**Related:** [FCE006](FCE006.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE007.fr.md b/doc/analyzers/FCE007.fr.md new file mode 100644 index 0000000..b278644 --- /dev/null +++ b/doc/analyzers/FCE007.fr.md @@ -0,0 +1,35 @@ +# FCE007: DocumentedByInvalidSignature + +| | | +|---|---| +| **Catégorie** | Câblage de la documentation (`FirstClassErrors.DocumentationWiring`) | +| **Sévérité** | 🔴 Error | +| **Activée par défaut** | Oui | + +La méthode référencée par `[DocumentedBy]` existe mais ne peut pas servir de factory de documentation. Une méthode de documentation doit être **statique**, **sans paramètre**, et retourner **`ErrorDocumentation`**. + +## Non conforme + +```csharp +[DocumentedBy(nameof(Doc))] +internal static DomainError Boom() => /* ... */; + +private static string Doc() => "not a documentation"; // FCE007: wrong return type +``` + +## Conforme + +```csharp +private static ErrorDocumentation Doc() => + DescribeError.WithTitle("...").WithDescription("...").WithoutRule().WithoutDiagnostic().WithExamples(); +``` + +## Détails + +Une cible manquante n'est pas du ressort de cette règle ; voir [FCE006](FCE006.fr.md). + +**Voir aussi:** [FCE006](FCE006.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE008.en.md b/doc/analyzers/FCE008.en.md new file mode 100644 index 0000000..d438e83 --- /dev/null +++ b/doc/analyzers/FCE008.en.md @@ -0,0 +1,38 @@ +# FCE008: DocumentedByWithoutProvidesErrorsFor + +| | | +|---|---| +| **Category** | Documentation wiring (`FirstClassErrors.DocumentationWiring`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +A type declares `[DocumentedBy]` factories but is missing `[ProvidesErrorsFor]`. Documentation extraction only scans types annotated with `[ProvidesErrorsFor]`, so every documented error on such a type is silently ignored. + +## Noncompliant + +```csharp +public static class SampleError { // FCE008: missing [ProvidesErrorsFor] + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => /* ... */; + + private static ErrorDocumentation Doc() => /* ... */; +} +``` + +## Compliant + +```csharp +[ProvidesErrorsFor(nameof(Sample))] +public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => /* ... */; + + private static ErrorDocumentation Doc() => /* ... */; +} +``` + +**Related:** [FCE006](FCE006.en.md), [FCE009](FCE009.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE008.fr.md b/doc/analyzers/FCE008.fr.md new file mode 100644 index 0000000..22b95c1 --- /dev/null +++ b/doc/analyzers/FCE008.fr.md @@ -0,0 +1,38 @@ +# FCE008: DocumentedByWithoutProvidesErrorsFor + +| | | +|---|---| +| **Catégorie** | Câblage de la documentation (`FirstClassErrors.DocumentationWiring`) | +| **Sévérité** | 🔴 Error | +| **Activée par défaut** | Oui | + +Un type déclare des factories `[DocumentedBy]` mais n'a pas `[ProvidesErrorsFor]`. L'extraction de documentation ne parcourt que les types annotés `[ProvidesErrorsFor]` : chaque erreur documentée d'un tel type est donc silencieusement ignorée. + +## Non conforme + +```csharp +public static class SampleError { // FCE008: missing [ProvidesErrorsFor] + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => /* ... */; + + private static ErrorDocumentation Doc() => /* ... */; +} +``` + +## Conforme + +```csharp +[ProvidesErrorsFor(nameof(Sample))] +public static class SampleError { + [DocumentedBy(nameof(Doc))] + internal static DomainError Boom() => /* ... */; + + private static ErrorDocumentation Doc() => /* ... */; +} +``` + +**Voir aussi:** [FCE006](FCE006.fr.md), [FCE009](FCE009.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE009.en.md b/doc/analyzers/FCE009.en.md new file mode 100644 index 0000000..57ddb95 --- /dev/null +++ b/doc/analyzers/FCE009.en.md @@ -0,0 +1,35 @@ +# FCE009: ErrorFactoryNotDocumented + +| | | +|---|---| +| **Category** | Documentation wiring (`FirstClassErrors.DocumentationWiring`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +A non-private static factory that returns an `Error` in a `[ProvidesErrorsFor]` type carries no `[DocumentedBy]`. The error is never added to the generated catalog. + +## Noncompliant + +```csharp +[ProvidesErrorsFor(nameof(Sample))] +public static class SampleError { + internal static DomainError Boom() => /* ... */; // FCE009: no [DocumentedBy] +} +``` + +## Compliant + +```csharp +[DocumentedBy(nameof(BoomDocumentation))] +internal static DomainError Boom() => /* ... */; +``` + +## Details + +Private methods are treated as helpers and left alone. + +**Related:** [FCE008](FCE008.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE009.fr.md b/doc/analyzers/FCE009.fr.md new file mode 100644 index 0000000..6e18948 --- /dev/null +++ b/doc/analyzers/FCE009.fr.md @@ -0,0 +1,35 @@ +# FCE009: ErrorFactoryNotDocumented + +| | | +|---|---| +| **Catégorie** | Câblage de la documentation (`FirstClassErrors.DocumentationWiring`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +Une factory statique non privée qui retourne une `Error` dans un type `[ProvidesErrorsFor]` ne porte pas `[DocumentedBy]`. L'erreur n'est jamais ajoutée au catalogue généré. + +## Non conforme + +```csharp +[ProvidesErrorsFor(nameof(Sample))] +public static class SampleError { + internal static DomainError Boom() => /* ... */; // FCE009: no [DocumentedBy] +} +``` + +## Conforme + +```csharp +[DocumentedBy(nameof(BoomDocumentation))] +internal static DomainError Boom() => /* ... */; +``` + +## Détails + +Les méthodes privées sont considérées comme des helpers et laissées de côté. + +**Voir aussi:** [FCE008](FCE008.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE010.en.md b/doc/analyzers/FCE010.en.md new file mode 100644 index 0000000..91479e5 --- /dev/null +++ b/doc/analyzers/FCE010.en.md @@ -0,0 +1,35 @@ +# FCE010: MultipleFactoriesShareDocumentation + +| | | +|---|---| +| **Category** | Documentation wiring (`FirstClassErrors.DocumentationWiring`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +Two or more factories in the same type point `[DocumentedBy]` at the same documentation method. One documentation method describes one error (its title, description, and examples), so sharing it means at least one error is mis-documented. + +## Noncompliant + +```csharp +[DocumentedBy(nameof(Doc))] +internal static DomainError A() => /* ... */; + +[DocumentedBy(nameof(Doc))] // FCE010 +internal static DomainError B() => /* ... */; +``` + +## Compliant + +```csharp +[DocumentedBy(nameof(DocA))] +internal static DomainError A() => /* ... */; + +[DocumentedBy(nameof(DocB))] +internal static DomainError B() => /* ... */; +``` + +**Related:** [FCE011](FCE011.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE010.fr.md b/doc/analyzers/FCE010.fr.md new file mode 100644 index 0000000..90ddce8 --- /dev/null +++ b/doc/analyzers/FCE010.fr.md @@ -0,0 +1,35 @@ +# FCE010: MultipleFactoriesShareDocumentation + +| | | +|---|---| +| **Catégorie** | Câblage de la documentation (`FirstClassErrors.DocumentationWiring`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +Deux factories (ou plus) du même type pointent leur `[DocumentedBy]` vers la même méthode de documentation. Une méthode de documentation décrit une erreur (son titre, sa description et ses exemples) : la partager signifie qu'au moins une erreur est mal documentée. + +## Non conforme + +```csharp +[DocumentedBy(nameof(Doc))] +internal static DomainError A() => /* ... */; + +[DocumentedBy(nameof(Doc))] // FCE010 +internal static DomainError B() => /* ... */; +``` + +## Conforme + +```csharp +[DocumentedBy(nameof(DocA))] +internal static DomainError A() => /* ... */; + +[DocumentedBy(nameof(DocB))] +internal static DomainError B() => /* ... */; +``` + +**Voir aussi:** [FCE011](FCE011.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE011.en.md b/doc/analyzers/FCE011.en.md new file mode 100644 index 0000000..1467795 --- /dev/null +++ b/doc/analyzers/FCE011.en.md @@ -0,0 +1,35 @@ +# FCE011: DuplicateDocumentedCode + +| | | +|---|---| +| **Category** | Documentation content (`FirstClassErrors.DocumentationContent`) | +| **Severity** | 🔴 Error | +| **Enabled by default** | Yes | + +More than one documented factory produces the same error code by referencing the same `ErrorCode` field. Documentation extraction groups by code and keeps a single entry, so the others collapse silently. This complements [FCE001](FCE001.en.md), which only sees duplicate `ErrorCode.Create` literals (a shared field has just one). + +## Noncompliant + +```csharp +[DocumentedBy(nameof(DocA))] +internal static DomainError A() => DomainError.Create(Code.Shared, "...").WithPublicMessage("..."); + +[DocumentedBy(nameof(DocB))] +internal static DomainError B() => DomainError.Create(Code.Shared, "...").WithPublicMessage("..."); // FCE011: same code +``` + +## Compliant + +```csharp +// A() uses Code.A, B() uses Code.B — one documented error per code. +``` + +## Details + +This is a compilation-end diagnostic: it appears at build / full-solution analysis. + +**Related:** [FCE001](FCE001.en.md), [FCE010](FCE010.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE011.fr.md b/doc/analyzers/FCE011.fr.md new file mode 100644 index 0000000..8b6a85f --- /dev/null +++ b/doc/analyzers/FCE011.fr.md @@ -0,0 +1,35 @@ +# FCE011: DuplicateDocumentedCode + +| | | +|---|---| +| **Catégorie** | Contenu de la documentation (`FirstClassErrors.DocumentationContent`) | +| **Sévérité** | 🔴 Error | +| **Activée par défaut** | Oui | + +Plus d'une factory documentée produit le même code d'erreur en référençant le même champ `ErrorCode`. L'extraction de documentation regroupe par code et n'en garde qu'une entrée : les autres disparaissent silencieusement. Complète [FCE001](FCE001.fr.md), qui ne voit que les littéraux `ErrorCode.Create` dupliqués (un champ partagé n'en a qu'un). + +## Non conforme + +```csharp +[DocumentedBy(nameof(DocA))] +internal static DomainError A() => DomainError.Create(Code.Shared, "...").WithPublicMessage("..."); + +[DocumentedBy(nameof(DocB))] +internal static DomainError B() => DomainError.Create(Code.Shared, "...").WithPublicMessage("..."); // FCE011: same code +``` + +## Conforme + +```csharp +// A() uses Code.A, B() uses Code.B — one documented error per code. +``` + +## Détails + +C'est un diagnostic de fin de compilation : il apparaît au build / à l'analyse de la solution entière. + +**Voir aussi:** [FCE001](FCE001.fr.md), [FCE010](FCE010.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE012.en.md b/doc/analyzers/FCE012.en.md new file mode 100644 index 0000000..74fa03c --- /dev/null +++ b/doc/analyzers/FCE012.en.md @@ -0,0 +1,29 @@ +# FCE012: EmptyExamples + +| | | +|---|---| +| **Category** | Documentation content (`FirstClassErrors.DocumentationContent`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +The terminal `WithExamples()` call of the documentation DSL is given no example factory. The call is mandatory (it produces the `ErrorDocumentation`) but may be called empty, yielding documentation that shows no realistic message. + +## Noncompliant + +```csharp +DescribeError.WithTitle("...").WithDescription("...").WithoutRule().WithoutDiagnostic() + .WithExamples(); // FCE012 +``` + +## Compliant + +```csharp +DescribeError.WithTitle("...").WithDescription("...").WithoutRule().WithoutDiagnostic() + .WithExamples(() => Boom()); +``` + +**Related:** [FCE013](FCE013.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE012.fr.md b/doc/analyzers/FCE012.fr.md new file mode 100644 index 0000000..0e6d162 --- /dev/null +++ b/doc/analyzers/FCE012.fr.md @@ -0,0 +1,29 @@ +# FCE012: EmptyExamples + +| | | +|---|---| +| **Catégorie** | Contenu de la documentation (`FirstClassErrors.DocumentationContent`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +L'appel terminal `WithExamples()` du DSL de documentation ne reçoit aucune factory d'exemple. L'appel est obligatoire (il produit l'`ErrorDocumentation`) mais peut être vide, produisant une documentation qui ne montre aucun message réaliste. + +## Non conforme + +```csharp +DescribeError.WithTitle("...").WithDescription("...").WithoutRule().WithoutDiagnostic() + .WithExamples(); // FCE012 +``` + +## Conforme + +```csharp +DescribeError.WithTitle("...").WithDescription("...").WithoutRule().WithoutDiagnostic() + .WithExamples(() => Boom()); +``` + +**Voir aussi:** [FCE013](FCE013.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE013.en.md b/doc/analyzers/FCE013.en.md new file mode 100644 index 0000000..a705b44 --- /dev/null +++ b/doc/analyzers/FCE013.en.md @@ -0,0 +1,32 @@ +# FCE013: ExampleDoesNotCallDocumentedFactory + +| | | +|---|---| +| **Category** | Documentation content (`FirstClassErrors.DocumentationContent`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +An example passed to `WithExamples(...)` does not invoke any factory of the type that declares the documentation. Examples exist to expose the documented error's real messages, so each should build that error. + +## Noncompliant + +```csharp +// inside SampleError's documentation method +.WithExamples(() => Other.Build()); // FCE013: Other is a different type +``` + +## Compliant + +```csharp +.WithExamples(() => Boom()); // Boom is a factory of SampleError +``` + +## Details + +Recognized example shapes are lambdas and method groups. An unrecognized shape is left alone to avoid false positives. + +**Related:** [FCE012](FCE012.en.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE013.fr.md b/doc/analyzers/FCE013.fr.md new file mode 100644 index 0000000..84b88fa --- /dev/null +++ b/doc/analyzers/FCE013.fr.md @@ -0,0 +1,32 @@ +# FCE013: ExampleDoesNotCallDocumentedFactory + +| | | +|---|---| +| **Catégorie** | Contenu de la documentation (`FirstClassErrors.DocumentationContent`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +Un exemple passé à `WithExamples(...)` n'appelle aucune factory du type qui déclare la documentation. Les exemples existent pour exposer les vrais messages de l'erreur documentée : chacun devrait donc construire cette erreur. + +## Non conforme + +```csharp +// inside SampleError's documentation method +.WithExamples(() => Other.Build()); // FCE013: Other is a different type +``` + +## Conforme + +```csharp +.WithExamples(() => Boom()); // Boom is a factory of SampleError +``` + +## Détails + +Les formes d'exemple reconnues sont les lambdas et les groupes de méthodes. Une forme non reconnue est laissée de côté pour éviter les faux positifs. + +**Voir aussi:** [FCE012](FCE012.fr.md) + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE014.en.md b/doc/analyzers/FCE014.en.md new file mode 100644 index 0000000..afad89c --- /dev/null +++ b/doc/analyzers/FCE014.en.md @@ -0,0 +1,25 @@ +# FCE014: ShortMessageSameAsDetailedMessage + +| | | +|---|---| +| **Category** | Documentation content (`FirstClassErrors.DocumentationContent`) | +| **Severity** | 🔵 Info | +| **Enabled by default** | Yes | + +`WithPublicMessage(short, detailed)` is called with two identical literal messages. The short message is a public summary and the detailed one an optional public detail, so identical values usually signal a copy-paste. + +## Noncompliant + +```csharp +.WithPublicMessage("Temperature is invalid.", "Temperature is invalid."); // FCE014 +``` + +## Compliant + +```csharp +.WithPublicMessage("Temperature is invalid.", "The temperature -280 Celsius is below absolute zero."); +``` + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE014.fr.md b/doc/analyzers/FCE014.fr.md new file mode 100644 index 0000000..bb12bb4 --- /dev/null +++ b/doc/analyzers/FCE014.fr.md @@ -0,0 +1,25 @@ +# FCE014: ShortMessageSameAsDetailedMessage + +| | | +|---|---| +| **Catégorie** | Contenu de la documentation (`FirstClassErrors.DocumentationContent`) | +| **Sévérité** | 🔵 Info | +| **Activée par défaut** | Oui | + +`WithPublicMessage(short, detailed)` est appelé avec deux messages littéraux identiques. Le message court est un résumé public et le détaillé un détail public optionnel : des valeurs identiques signalent en général un copier-coller. + +## Non conforme + +```csharp +.WithPublicMessage("Temperature is invalid.", "Temperature is invalid."); // FCE014 +``` + +## Conforme + +```csharp +.WithPublicMessage("Temperature is invalid.", "The temperature -280 Celsius is below absolute zero."); +``` + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE015.en.md b/doc/analyzers/FCE015.en.md new file mode 100644 index 0000000..d3627e0 --- /dev/null +++ b/doc/analyzers/FCE015.en.md @@ -0,0 +1,35 @@ +# FCE015: DocumentationTitleTooGeneric + +| | | +|---|---| +| **Category** | Documentation content (`FirstClassErrors.DocumentationContent`) | +| **Severity** | 🔵 Info | +| **Enabled by default** | No (opt-in) | + +A `WithTitle("...")` uses a title that describes nothing (`Error`, `Invalid value`, `Failure`, ...). A good title names the condition. + +## Noncompliant + +```csharp +DescribeError.WithTitle("Invalid value") // FCE015 + .WithDescription("...")/* ... */; +``` + +## Compliant + +```csharp +DescribeError.WithTitle("Temperature below absolute zero") + .WithDescription("...")/* ... */; +``` + +## Enabling this rule + +This rule is disabled by default. Turn it on in `.editorconfig`: + +```ini +dotnet_diagnostic.FCE015.severity = suggestion # or warning / error +``` + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE015.fr.md b/doc/analyzers/FCE015.fr.md new file mode 100644 index 0000000..43c2826 --- /dev/null +++ b/doc/analyzers/FCE015.fr.md @@ -0,0 +1,35 @@ +# FCE015: DocumentationTitleTooGeneric + +| | | +|---|---| +| **Catégorie** | Contenu de la documentation (`FirstClassErrors.DocumentationContent`) | +| **Sévérité** | 🔵 Info | +| **Activée par défaut** | Non (opt-in) | + +Un `WithTitle("...")` utilise un titre qui ne décrit rien (`Error`, `Invalid value`, `Failure`, …). Un bon titre nomme la condition. + +## Non conforme + +```csharp +DescribeError.WithTitle("Invalid value") // FCE015 + .WithDescription("...")/* ... */; +``` + +## Conforme + +```csharp +DescribeError.WithTitle("Temperature below absolute zero") + .WithDescription("...")/* ... */; +``` + +## Activer cette règle + +Cette règle est désactivée par défaut. Activez-la dans `.editorconfig` : + +```ini +dotnet_diagnostic.FCE015.severity = suggestion # ou warning / error +``` + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/FCE016.en.md b/doc/analyzers/FCE016.en.md new file mode 100644 index 0000000..0031070 --- /dev/null +++ b/doc/analyzers/FCE016.en.md @@ -0,0 +1,29 @@ +# FCE016: UnusedToExceptionResult + +| | | +|---|---| +| **Category** | Usage (`FirstClassErrors.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +`Error.ToException()` is called as a standalone statement and its result is discarded. `ToException()` only builds the exception; without a `throw` (or capturing the result) nothing happens and the error is lost. + +## Noncompliant + +```csharp +public void Guard(DomainError error) { + error.ToException(); // FCE016: result discarded +} +``` + +## Compliant + +```csharp +public void Guard(DomainError error) { + throw error.ToException(); +} +``` + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE016.fr.md b/doc/analyzers/FCE016.fr.md new file mode 100644 index 0000000..5e1e9dc --- /dev/null +++ b/doc/analyzers/FCE016.fr.md @@ -0,0 +1,29 @@ +# FCE016: UnusedToExceptionResult + +| | | +|---|---| +| **Catégorie** | Usage (`FirstClassErrors.Usage`) | +| **Sévérité** | 🟠 Warning | +| **Activée par défaut** | Oui | + +`Error.ToException()` est appelé comme instruction isolée et son résultat est ignoré. `ToException()` ne fait que construire l'exception : sans `throw` (ou sans capturer le résultat), rien ne se passe et l'erreur est perdue. + +## Non conforme + +```csharp +public void Guard(DomainError error) { + error.ToException(); // FCE016: result discarded +} +``` + +## Conforme + +```csharp +public void Guard(DomainError error) { + throw error.ToException(); +} +``` + +--- + +[← Toutes les règles d'analyse FirstClassErrors](README.fr.md) diff --git a/doc/analyzers/README.fr.md b/doc/analyzers/README.fr.md new file mode 100644 index 0000000..aa78af9 --- /dev/null +++ b/doc/analyzers/README.fr.md @@ -0,0 +1,55 @@ +# Analyseurs FirstClassErrors + +Analyseurs Roslyn qui détectent, au build, les erreurs que le runtime et le pipeline de documentation de FirstClassErrors ne feraient sinon apparaître que tard, voire silencieusement. Ils sont **inclus dans le package NuGet `FirstClassErrors`** — tout projet qui le référence obtient les règles automatiquement, sans installation. + +Chaque règle a un identifiant stable `FCExxx`. Les erreurs sont des défauts durs ; les avertissements signalent des fautes probables ; les règles d'info sont des conventions, et plusieurs sont opt-in (voir chaque page pour les activer). + +## Codes d'erreur + +| Règle | Sévérité | Défaut | Description | +|------|----------|---------|-------------| +| [FCE001 DuplicateErrorCode](FCE001.fr.md) | 🔴 Error | activée | Le même code d'erreur littéral est créé par plus d'un ErrorCode.Create("...") dans la compilation. | +| [FCE002 EmptyErrorCode](FCE002.fr.md) | 🔴 Error | activée | ErrorCode.Create est appelé avec un littéral vide, composé d'espaces, ou null. | +| [FCE003 NonLiteralErrorCode](FCE003.fr.md) | 🔵 Info | opt-in | ErrorCode.Create est appelé avec un argument qui n'est pas une constante de compilation. | +| [FCE004 InvalidErrorCodeFormat](FCE004.fr.md) | 🔵 Info | opt-in | Un code d'erreur littéral ne respecte pas la convention UPPER_SNAKE_CASE. | +| [FCE005 TooGenericErrorCode](FCE005.fr.md) | 🔵 Info | opt-in | Un code d'erreur littéral fait partie d'un petit ensemble de mots fourre-tout (ERROR, INVALID, FAILED, …) sans valeur diagnostique. | + +## Câblage de la documentation + +| Règle | Sévérité | Défaut | Description | +|------|----------|---------|-------------| +| [FCE006 DocumentedByTargetNotFound](FCE006.fr.md) | 🔴 Error | activée | Un [DocumentedBy("...")] désigne une méthode de documentation qui n'existe pas sur le type contenant. | +| [FCE007 DocumentedByInvalidSignature](FCE007.fr.md) | 🔴 Error | activée | La méthode référencée par [DocumentedBy] existe mais ne peut pas servir de factory de documentation. | +| [FCE008 DocumentedByWithoutProvidesErrorsFor](FCE008.fr.md) | 🔴 Error | activée | Un type déclare des factories [DocumentedBy] mais n'a pas [ProvidesErrorsFor]. | +| [FCE009 ErrorFactoryNotDocumented](FCE009.fr.md) | 🟠 Warning | activée | Une factory statique non privée qui retourne une Error dans un type [ProvidesErrorsFor] ne porte pas [DocumentedBy]. | +| [FCE010 MultipleFactoriesShareDocumentation](FCE010.fr.md) | 🟠 Warning | activée | Deux factories (ou plus) du même type pointent leur [DocumentedBy] vers la même méthode de documentation. | + +## Contenu de la documentation + +| Règle | Sévérité | Défaut | Description | +|------|----------|---------|-------------| +| [FCE011 DuplicateDocumentedCode](FCE011.fr.md) | 🔴 Error | activée | Plus d'une factory documentée produit le même code d'erreur en référençant le même champ ErrorCode. | +| [FCE012 EmptyExamples](FCE012.fr.md) | 🟠 Warning | activée | L'appel terminal WithExamples() du DSL de documentation ne reçoit aucune factory d'exemple. | +| [FCE013 ExampleDoesNotCallDocumentedFactory](FCE013.fr.md) | 🟠 Warning | activée | Un exemple passé à WithExamples(...) n'appelle aucune factory du type qui déclare la documentation. | +| [FCE014 ShortMessageSameAsDetailedMessage](FCE014.fr.md) | 🔵 Info | activée | WithPublicMessage(short, detailed) est appelé avec deux messages littéraux identiques. | +| [FCE015 DocumentationTitleTooGeneric](FCE015.fr.md) | 🔵 Info | opt-in | Un WithTitle("...") utilise un titre qui ne décrit rien (Error, Invalid value, Failure, …). | + +## Usage + +| Règle | Sévérité | Défaut | Description | +|------|----------|---------|-------------| +| [FCE016 UnusedToExceptionResult](FCE016.fr.md) | 🟠 Warning | activée | Error.ToException() est appelé comme instruction isolée et son résultat est ignoré. | + +## Configuration + +La sévérité de chaque règle se règle dans `.editorconfig`, par exemple : + +```ini +# activer une règle opt-in +dotnet_diagnostic.FCE004.severity = warning + +# ou faire taire une règle +dotnet_diagnostic.FCE014.severity = none +``` + +> `FCE001` et `FCE011` sont des vérifications sur toute la compilation : elles apparaissent au build / à l'analyse de la solution entière, pas à la frappe dans un seul fichier. diff --git a/doc/analyzers/README.md b/doc/analyzers/README.md new file mode 100644 index 0000000..d1d0650 --- /dev/null +++ b/doc/analyzers/README.md @@ -0,0 +1,55 @@ +# FirstClassErrors analyzers + +Roslyn analyzers that catch, at build time, the mistakes the FirstClassErrors runtime and documentation pipeline would otherwise surface late or silently. They ship **inside the `FirstClassErrors` NuGet package** — any project that references it gets the rules automatically, no extra install. + +Each rule has a stable id `FCExxx`. Errors are hard defects; warnings flag likely mistakes; the info rules are conventions, and several are opt-in (see each page for how to enable them). + +## Error codes + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [FCE001 DuplicateErrorCode](FCE001.en.md) | 🔴 Error | on | The same literal error code is created by more than one ErrorCode.Create("...") in the compilation. | +| [FCE002 EmptyErrorCode](FCE002.en.md) | 🔴 Error | on | ErrorCode.Create is called with an empty, whitespace, or null literal. | +| [FCE003 NonLiteralErrorCode](FCE003.en.md) | 🔵 Info | opt-in | ErrorCode.Create is called with an argument that is not a compile-time constant. | +| [FCE004 InvalidErrorCodeFormat](FCE004.en.md) | 🔵 Info | opt-in | A literal error code does not follow the UPPER_SNAKE_CASE convention. | +| [FCE005 TooGenericErrorCode](FCE005.en.md) | 🔵 Info | opt-in | A literal error code is one of a small set of catch-all words (ERROR, INVALID, FAILED, ...) that carry no diagnostic value. | + +## Documentation wiring + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [FCE006 DocumentedByTargetNotFound](FCE006.en.md) | 🔴 Error | on | A [DocumentedBy("...")] names a documentation method that does not exist on the containing type. | +| [FCE007 DocumentedByInvalidSignature](FCE007.en.md) | 🔴 Error | on | The method referenced by [DocumentedBy] exists but cannot be used as a documentation factory. | +| [FCE008 DocumentedByWithoutProvidesErrorsFor](FCE008.en.md) | 🔴 Error | on | A type declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]. | +| [FCE009 ErrorFactoryNotDocumented](FCE009.en.md) | 🟠 Warning | on | A non-private static factory that returns an Error in a [ProvidesErrorsFor] type carries no [DocumentedBy]. | +| [FCE010 MultipleFactoriesShareDocumentation](FCE010.en.md) | 🟠 Warning | on | Two or more factories in the same type point [DocumentedBy] at the same documentation method. | + +## Documentation content + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [FCE011 DuplicateDocumentedCode](FCE011.en.md) | 🔴 Error | on | More than one documented factory produces the same error code by referencing the same ErrorCode field. | +| [FCE012 EmptyExamples](FCE012.en.md) | 🟠 Warning | on | The terminal WithExamples() call of the documentation DSL is given no example factory. | +| [FCE013 ExampleDoesNotCallDocumentedFactory](FCE013.en.md) | 🟠 Warning | on | An example passed to WithExamples(...) does not invoke any factory of the type that declares the documentation. | +| [FCE014 ShortMessageSameAsDetailedMessage](FCE014.en.md) | 🔵 Info | on | WithPublicMessage(short, detailed) is called with two identical literal messages. | +| [FCE015 DocumentationTitleTooGeneric](FCE015.en.md) | 🔵 Info | opt-in | A WithTitle("...") uses a title that describes nothing (Error, Invalid value, Failure, ...). | + +## Usage + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [FCE016 UnusedToExceptionResult](FCE016.en.md) | 🟠 Warning | on | Error.ToException() is called as a standalone statement and its result is discarded. | + +## Configuring + +Every rule's severity can be tuned in `.editorconfig`, for example: + +```ini +# turn an opt-in rule on +dotnet_diagnostic.FCE004.severity = warning + +# or silence a rule you do not want +dotnet_diagnostic.FCE014.severity = none +``` + +> `FCE001` and `FCE011` are whole-compilation checks: they appear at build / full-solution analysis rather than as you type in a single file.