From 9cd61d982deb5500b49a7b5105170ff51826baa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:38:42 +0000 Subject: [PATCH 01/32] chore(analyzers): scaffold analyzer + test projects, solution wiring and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the FirstClassErrors.Analyzers project (netstandard2.0) and its xUnit v3 test project, wired into the solution under the existing src/tests folders. Includes the diagnostic id/category catalog for the agreed 16 rules, a dependency-free in-process analyzer test harness (compiles a snippet against the running runtime + the FirstClassErrors core, runs one analyzer, returns its diagnostics), empty analyzer release-tracking files, and a GitHub Actions workflow that restores/builds/tests the analyzers. The repository has no CI; this workflow is the validation path since the development environment cannot build .NET locally. No diagnostic rules yet — those land one commit per FCExxx. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .github/workflows/analyzers.yml | 33 ++++++++++++ .../AnalyzerTestHarness.cs | 54 +++++++++++++++++++ ...irstClassErrors.Analyzers.UnitTests.csproj | 36 +++++++++++++ .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 2 + .../DiagnosticCategories.cs | 13 +++++ FirstClassErrors.Analyzers/DiagnosticIds.cs | 33 ++++++++++++ .../FirstClassErrors.Analyzers.csproj | 32 +++++++++++ FirstClassErrors.Analyzers/HelpLinks.cs | 15 ++++++ FirstClassErrors.sln | 14 +++++ 10 files changed, 234 insertions(+) create mode 100644 .github/workflows/analyzers.yml create mode 100644 FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs create mode 100644 FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj create mode 100644 FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md create mode 100644 FirstClassErrors.Analyzers/DiagnosticCategories.cs create mode 100644 FirstClassErrors.Analyzers/DiagnosticIds.cs create mode 100644 FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj create mode 100644 FirstClassErrors.Analyzers/HelpLinks.cs diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml new file mode 100644 index 0000000..caef97d --- /dev/null +++ b/.github/workflows/analyzers.yml @@ -0,0 +1,33 @@ +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" diff --git a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs new file mode 100644 index 0000000..ff9c0b3 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -0,0 +1,54 @@ +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) { + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source); + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "FirstClassErrors.Analyzers.TestSnippet", + syntaxTrees: new[] { syntaxTree }, + references: BaseReferences, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + 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/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..f2b7fad --- /dev/null +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,2 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md 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/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..8a59e71 --- /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}.md"; + } + +} 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} From 66c7932045b8e54c9533fe5c94acbe2c1ca0fcb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:40:34 +0000 Subject: [PATCH 02/32] feat(analyzers): add FCE002 EmptyErrorCode Report ErrorCode.Create("") / whitespace / null literal arguments, which throw an ArgumentException at runtime, as a build-time error. Only literal arguments are inspected; non-literal codes are out of scope (reserved for FCE003). Covered by four tests (empty, whitespace, valid, non-literal) exercised through the in-process analyzer harness. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce002EmptyErrorCodeTests.cs | 76 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 6 ++ FirstClassErrors.Analyzers/Descriptors.cs | 21 +++++ .../EmptyErrorCodeAnalyzer.cs | 56 ++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs create mode 100644 FirstClassErrors.Analyzers/Descriptors.cs create mode 100644 FirstClassErrors.Analyzers/EmptyErrorCodeAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index f2b7fad..89bc53f 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; 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 +--------|-----------------------------|----------|------------------------- +FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs new file mode 100644 index 0000000..bd117ca --- /dev/null +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -0,0 +1,21 @@ +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 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)); + +} 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())); + } + +} From d42ea98b17b8a1463c30c49398729d2bce8d498d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:55:38 +0000 Subject: [PATCH 03/32] feat(analyzers): add FCE006 DocumentedByTargetNotFound Report a [DocumentedBy("X")] whose referenced method 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. Introduces two shared helpers used here and by the next wiring rules: KnownSymbols (resolves FirstClassErrors types by metadata name) and SymbolFacts (attribute lookup + type-inheritance checks). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce006DocumentedByTargetNotFoundTests.cs | 70 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 5 +- FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ .../DocumentedByTargetNotFoundAnalyzer.cs | 53 ++++++++++++++ FirstClassErrors.Analyzers/KnownSymbols.cs | 30 ++++++++ FirstClassErrors.Analyzers/SymbolFacts.cs | 57 +++++++++++++++ 6 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs create mode 100644 FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs create mode 100644 FirstClassErrors.Analyzers/KnownSymbols.cs create mode 100644 FirstClassErrors.Analyzers/SymbolFacts.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 89bc53f..a12cdc7 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -4,5 +4,6 @@ ### New Rules Rule ID | Category | Severity | Notes ---------|-----------------------------|----------|------------------------- -FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer +--------|--------------------------------------|----------|------------------------------------- +FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer +FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index bd117ca..97eb47f 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -18,4 +18,14 @@ internal static class Descriptors { 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 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)); + } 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/KnownSymbols.cs b/FirstClassErrors.Analyzers/KnownSymbols.cs new file mode 100644 index 0000000..2e40fe7 --- /dev/null +++ b/FirstClassErrors.Analyzers/KnownSymbols.cs @@ -0,0 +1,30 @@ +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 DocumentedByAttributeMetadataName = "FirstClassErrors.DocumentedByAttribute"; + public const string ProvidesErrorsForAttributeMetadataName = "FirstClassErrors.ProvidesErrorsForAttribute"; + public const string ErrorDocumentationMetadataName = "FirstClassErrors.ErrorDocumentation"; + + private KnownSymbols(Compilation compilation) { + DocumentedByAttribute = compilation.GetTypeByMetadataName(DocumentedByAttributeMetadataName); + ProvidesErrorsForAttribute = compilation.GetTypeByMetadataName(ProvidesErrorsForAttributeMetadataName); + ErrorDocumentation = compilation.GetTypeByMetadataName(ErrorDocumentationMetadataName); + } + + 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/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; + } + +} From 0704efb8504baab7f45413f8e4b6253550fc9c2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:56:31 +0000 Subject: [PATCH 04/32] feat(analyzers): add FCE007 DocumentedByInvalidSignature Report a [DocumentedBy("X")] whose target method exists but cannot serve as a documentation factory: it must be static, parameterless and return ErrorDocumentation. A missing target stays FCE006's concern, so this rule is silent when no method of that name exists. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...Fce007DocumentedByInvalidSignatureTests.cs | 95 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 ++ .../DocumentedByInvalidSignatureAnalyzer.cs | 64 +++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs create mode 100644 FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index a12cdc7..7ac3bba 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,3 +7,4 @@ Rule ID | Category | Severity | Notes --------|--------------------------------------|----------|------------------------------------- FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer +FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 97eb47f..84abc93 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -28,4 +28,14 @@ internal static class Descriptors { 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)); + } 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); + } + +} From 10fb332ea14b2bcee1a4e887b43e911b1e1f1eb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:57:18 +0000 Subject: [PATCH 05/32] feat(analyzers): add FCE008 DocumentedByWithoutProvidesErrorsFor Report a type that declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]. Extraction only scans types carrying [ProvidesErrorsFor], so such documentation is silently ignored. Reported once per type. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...cumentedByWithoutProvidesErrorsForTests.cs | 71 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ ...entedByWithoutProvidesErrorsForAnalyzer.cs | 51 +++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs create mode 100644 FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 7ac3bba..cea9bf5 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -8,3 +8,4 @@ Rule ID | Category | Severity | Notes FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer +FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 84abc93..60c0427 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -38,4 +38,14 @@ internal static class Descriptors { 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)); + } 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)); + } + +} From db7c3cb49e9b8480f5aa6d958fabbebeed5d58ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:38:42 +0000 Subject: [PATCH 06/32] chore(analyzers): scaffold analyzer + test projects, solution wiring and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the FirstClassErrors.Analyzers project (netstandard2.0) and its xUnit v3 test project, wired into the solution under the existing src/tests folders. Includes the diagnostic id/category catalog for the agreed 16 rules, a dependency-free in-process analyzer test harness (compiles a snippet against the running runtime + the FirstClassErrors core, runs one analyzer, returns its diagnostics), empty analyzer release-tracking files, and a GitHub Actions workflow that restores/builds/tests the analyzers. The repository has no CI; this workflow is the validation path since the development environment cannot build .NET locally. No diagnostic rules yet — those land one commit per FCExxx. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .github/workflows/analyzers.yml | 33 ++++++++++++ .../AnalyzerTestHarness.cs | 54 +++++++++++++++++++ ...irstClassErrors.Analyzers.UnitTests.csproj | 36 +++++++++++++ .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 2 + .../DiagnosticCategories.cs | 13 +++++ FirstClassErrors.Analyzers/DiagnosticIds.cs | 33 ++++++++++++ .../FirstClassErrors.Analyzers.csproj | 32 +++++++++++ FirstClassErrors.Analyzers/HelpLinks.cs | 15 ++++++ FirstClassErrors.sln | 14 +++++ 10 files changed, 234 insertions(+) create mode 100644 .github/workflows/analyzers.yml create mode 100644 FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs create mode 100644 FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj create mode 100644 FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md create mode 100644 FirstClassErrors.Analyzers/DiagnosticCategories.cs create mode 100644 FirstClassErrors.Analyzers/DiagnosticIds.cs create mode 100644 FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj create mode 100644 FirstClassErrors.Analyzers/HelpLinks.cs diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml new file mode 100644 index 0000000..caef97d --- /dev/null +++ b/.github/workflows/analyzers.yml @@ -0,0 +1,33 @@ +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" diff --git a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs new file mode 100644 index 0000000..ff9c0b3 --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -0,0 +1,54 @@ +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) { + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source); + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "FirstClassErrors.Analyzers.TestSnippet", + syntaxTrees: new[] { syntaxTree }, + references: BaseReferences, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + 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/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..f2b7fad --- /dev/null +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,2 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md 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/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..8a59e71 --- /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}.md"; + } + +} 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} From 8229f61d518a97b78334466dffa438b4a7426c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:40:34 +0000 Subject: [PATCH 07/32] feat(analyzers): add FCE002 EmptyErrorCode Report ErrorCode.Create("") / whitespace / null literal arguments, which throw an ArgumentException at runtime, as a build-time error. Only literal arguments are inspected; non-literal codes are out of scope (reserved for FCE003). Covered by four tests (empty, whitespace, valid, non-literal) exercised through the in-process analyzer harness. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce002EmptyErrorCodeTests.cs | 76 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 6 ++ FirstClassErrors.Analyzers/Descriptors.cs | 21 +++++ .../EmptyErrorCodeAnalyzer.cs | 56 ++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs create mode 100644 FirstClassErrors.Analyzers/Descriptors.cs create mode 100644 FirstClassErrors.Analyzers/EmptyErrorCodeAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index f2b7fad..89bc53f 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; 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 +--------|-----------------------------|----------|------------------------- +FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs new file mode 100644 index 0000000..bd117ca --- /dev/null +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -0,0 +1,21 @@ +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 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)); + +} 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())); + } + +} From 25f33ab91f85a53fce3220cf3b67a341780288de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:55:38 +0000 Subject: [PATCH 08/32] feat(analyzers): add FCE006 DocumentedByTargetNotFound Report a [DocumentedBy("X")] whose referenced method 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. Introduces two shared helpers used here and by the next wiring rules: KnownSymbols (resolves FirstClassErrors types by metadata name) and SymbolFacts (attribute lookup + type-inheritance checks). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce006DocumentedByTargetNotFoundTests.cs | 70 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 5 +- FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ .../DocumentedByTargetNotFoundAnalyzer.cs | 53 ++++++++++++++ FirstClassErrors.Analyzers/KnownSymbols.cs | 30 ++++++++ FirstClassErrors.Analyzers/SymbolFacts.cs | 57 +++++++++++++++ 6 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs create mode 100644 FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs create mode 100644 FirstClassErrors.Analyzers/KnownSymbols.cs create mode 100644 FirstClassErrors.Analyzers/SymbolFacts.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 89bc53f..a12cdc7 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -4,5 +4,6 @@ ### New Rules Rule ID | Category | Severity | Notes ---------|-----------------------------|----------|------------------------- -FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer +--------|--------------------------------------|----------|------------------------------------- +FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer +FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index bd117ca..97eb47f 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -18,4 +18,14 @@ internal static class Descriptors { 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 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)); + } 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/KnownSymbols.cs b/FirstClassErrors.Analyzers/KnownSymbols.cs new file mode 100644 index 0000000..2e40fe7 --- /dev/null +++ b/FirstClassErrors.Analyzers/KnownSymbols.cs @@ -0,0 +1,30 @@ +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 DocumentedByAttributeMetadataName = "FirstClassErrors.DocumentedByAttribute"; + public const string ProvidesErrorsForAttributeMetadataName = "FirstClassErrors.ProvidesErrorsForAttribute"; + public const string ErrorDocumentationMetadataName = "FirstClassErrors.ErrorDocumentation"; + + private KnownSymbols(Compilation compilation) { + DocumentedByAttribute = compilation.GetTypeByMetadataName(DocumentedByAttributeMetadataName); + ProvidesErrorsForAttribute = compilation.GetTypeByMetadataName(ProvidesErrorsForAttributeMetadataName); + ErrorDocumentation = compilation.GetTypeByMetadataName(ErrorDocumentationMetadataName); + } + + 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/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; + } + +} From 42551f455f05a68042840626428e601821a47626 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:56:31 +0000 Subject: [PATCH 09/32] feat(analyzers): add FCE007 DocumentedByInvalidSignature Report a [DocumentedBy("X")] whose target method exists but cannot serve as a documentation factory: it must be static, parameterless and return ErrorDocumentation. A missing target stays FCE006's concern, so this rule is silent when no method of that name exists. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...Fce007DocumentedByInvalidSignatureTests.cs | 95 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 ++ .../DocumentedByInvalidSignatureAnalyzer.cs | 64 +++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs create mode 100644 FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index a12cdc7..7ac3bba 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,3 +7,4 @@ Rule ID | Category | Severity | Notes --------|--------------------------------------|----------|------------------------------------- FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer +FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 97eb47f..84abc93 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -28,4 +28,14 @@ internal static class Descriptors { 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)); + } 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); + } + +} From 1110bd90366a90aca8cc009aea4091673f8c9dee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:57:18 +0000 Subject: [PATCH 10/32] feat(analyzers): add FCE008 DocumentedByWithoutProvidesErrorsFor Report a type that declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]. Extraction only scans types carrying [ProvidesErrorsFor], so such documentation is silently ignored. Reported once per type. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...cumentedByWithoutProvidesErrorsForTests.cs | 71 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ ...entedByWithoutProvidesErrorsForAnalyzer.cs | 51 +++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs create mode 100644 FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 7ac3bba..cea9bf5 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -8,3 +8,4 @@ Rule ID | Category | Severity | Notes FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer +FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 84abc93..60c0427 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -38,4 +38,14 @@ internal static class Descriptors { 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)); + } 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)); + } + +} From 364cae4b157afe2f4fedc78aad88a28dc39f6a28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 16:49:05 +0000 Subject: [PATCH 11/32] feat(analyzers): add FCE001 DuplicateErrorCode Report the same literal error code created by more than one ErrorCode.Create("X") in the compilation, lighting up every participating site. ErrorCode.Create registers each code in a process-wide set and throws when a code is created twice; this shifts the failure to build time. Detection aggregates occurrences across the whole compilation (CompilationStart -> operation collect -> CompilationEnd report) with ordinal comparison, matching the runtime registry. Cross-assembly duplicates and non-literal codes remain out of scope (the latter is FCE003); empty codes are left to FCE002. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce001DuplicateErrorCodeTests.cs | 98 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 ++ .../DuplicateErrorCodeAnalyzer.cs | 81 +++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce001DuplicateErrorCodeTests.cs create mode 100644 FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index cea9bf5..5811ab8 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -5,6 +5,7 @@ Rule ID | Category | Severity | Notes --------|--------------------------------------|----------|------------------------------------- +FCE001 | FirstClassErrors.ErrorCodes | Error | DuplicateErrorCodeAnalyzer FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 60c0427..1fa3138 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -8,6 +8,16 @@ namespace FirstClassErrors.Analyzers; /// 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)); + public static readonly DiagnosticDescriptor EmptyErrorCode = new( id: DiagnosticIds.EmptyErrorCode, title: "Error code must not be empty", 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)); + } + } + } + +} From e82f3b87253ce42f9e0497e6d8a41054cd608b17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:01:38 +0000 Subject: [PATCH 12/32] feat(analyzers): add FCE016 UnusedToExceptionResult Report a call to Error.ToException() whose result is discarded as a standalone statement. ToException() only builds the exception; without a throw (or capturing the result) the error is silently lost. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce016UnusedToExceptionResultTests.cs | 65 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ .../UnusedToExceptionResultAnalyzer.cs | 51 +++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce016UnusedToExceptionResultTests.cs create mode 100644 FirstClassErrors.Analyzers/UnusedToExceptionResultAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 5811ab8..61a087e 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -10,3 +10,4 @@ FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyz FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer +FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 1fa3138..6afb6ae 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -58,4 +58,14 @@ internal static class Descriptors { 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)); + } 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())); + } + +} From e8ea977457cba6404369a7eb5c5530147d4ee52d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:02:38 +0000 Subject: [PATCH 13/32] feat(analyzers): add FCE009 ErrorFactoryNotDocumented Report a non-private static factory in a [ProvidesErrorsFor] type that returns an Error but has no [DocumentedBy]; such an error is left out of the generated catalog. Private methods are treated as helpers and skipped to limit false positives. Adds Error to the shared KnownSymbols resolver. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce009ErrorFactoryNotDocumentedTests.cs | 85 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ .../ErrorFactoryNotDocumentedAnalyzer.cs | 49 +++++++++++ FirstClassErrors.Analyzers/KnownSymbols.cs | 3 + 5 files changed, 148 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce009ErrorFactoryNotDocumentedTests.cs create mode 100644 FirstClassErrors.Analyzers/ErrorFactoryNotDocumentedAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 61a087e..fd21cbd 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -10,4 +10,5 @@ FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyz FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer +FCE009 | FirstClassErrors.DocumentationWiring | Warning | ErrorFactoryNotDocumentedAnalyzer FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 6afb6ae..2551ebe 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -68,4 +68,14 @@ internal static class Descriptors { 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)); + } 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/KnownSymbols.cs b/FirstClassErrors.Analyzers/KnownSymbols.cs index 2e40fe7..afcb951 100644 --- a/FirstClassErrors.Analyzers/KnownSymbols.cs +++ b/FirstClassErrors.Analyzers/KnownSymbols.cs @@ -9,16 +9,19 @@ namespace FirstClassErrors.Analyzers; /// 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; } From ee4e6faead546d60fd6995f2a279ccc7c2bf7934 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:03:31 +0000 Subject: [PATCH 14/32] feat(analyzers): add FCE010 MultipleFactoriesShareDocumentation Report factories in the same type whose [DocumentedBy] reference the same documentation method. One documentation method describes one error, so sharing it (title, description, examples) means at least one error is mis-documented. Every sharing factory is flagged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...ultipleFactoriesShareDocumentationTests.cs | 67 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ ...ipleFactoriesShareDocumentationAnalyzer.cs | 67 +++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce010MultipleFactoriesShareDocumentationTests.cs create mode 100644 FirstClassErrors.Analyzers/MultipleFactoriesShareDocumentationAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index fd21cbd..1168d39 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -11,4 +11,5 @@ FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNo FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer FCE009 | FirstClassErrors.DocumentationWiring | Warning | ErrorFactoryNotDocumentedAnalyzer +FCE010 | FirstClassErrors.DocumentationWiring | Warning | MultipleFactoriesShareDocumentationAnalyzer FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 2551ebe..9f47df9 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -78,4 +78,14 @@ internal static class Descriptors { 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)); + } 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)); + } + } + } + +} From fa43e3873d390a51a58a51b6e9e5119f10aae02a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:04:20 +0000 Subject: [PATCH 15/32] feat(analyzers): add FCE012 EmptyExamples Report the terminal WithExamples() call of the documentation DSL when given no example factory. The call is mandatory (it produces ErrorDocumentation) but may be called empty, yielding documentation that shows no realistic message. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce012EmptyExamplesTests.cs | 51 ++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ .../EmptyExamplesAnalyzer.cs | 61 +++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce012EmptyExamplesTests.cs create mode 100644 FirstClassErrors.Analyzers/EmptyExamplesAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 1168d39..47bdd3b 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -12,4 +12,5 @@ FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidS FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer FCE009 | FirstClassErrors.DocumentationWiring | Warning | ErrorFactoryNotDocumentedAnalyzer FCE010 | FirstClassErrors.DocumentationWiring | Warning | MultipleFactoriesShareDocumentationAnalyzer +FCE012 | FirstClassErrors.DocumentationContent| Warning | EmptyExamplesAnalyzer FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 9f47df9..a9a68a6 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -88,4 +88,14 @@ internal static class Descriptors { 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)); + } 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 }; + } + +} From fbe852f682b1eb8bcb0a713c8370d0e76c9e5073 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:13:08 +0000 Subject: [PATCH 16/32] build(analyzers): bundle the analyzer into the FirstClassErrors package Pack FirstClassErrors.Analyzers.dll into analyzers/dotnet/cs of the core NuGet package via TargetsForTfmSpecificContentInPackage, so any project referencing the FirstClassErrors package gets the FCExxx rules automatically. The ProjectReference is ReferenceOutputAssembly=false + PrivateAssets=all: build-order only, no runtime or dependency-graph impact. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- FirstClassErrors/FirstClassErrors.csproj | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From e96884c89c18dead5b4797406d325bd4db8ab22a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:13:39 +0000 Subject: [PATCH 17/32] build(analyzers): dogfood the analyzers on FirstClassErrors.Usage and cover it in CI Reference the analyzer from the sample project as OutputItemType=Analyzer so the FCExxx rules run at build/IDE time on real usage code, and add a CI step that builds the sample with the analyzers active. This fails on any Error-severity finding and surfaces warnings in the log, keeping the sample exemplary. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .github/workflows/analyzers.yml | 5 +++++ FirstClassErrors.Usage/FirstClassErrors.Usage.csproj | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml index caef97d..dbd4a0f 100644 --- a/.github/workflows/analyzers.yml +++ b/.github/workflows/analyzers.yml @@ -31,3 +31,8 @@ jobs: - 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.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 From 8aca050b1f544dbd0e195bffcc9f783e36845ad5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:35:40 +0000 Subject: [PATCH 18/32] fix(analyzers): tag FCE001 as a compilation-end diagnostic (RS1037) DuplicateErrorCode is reported from a RegisterCompilationEndAction, so its descriptor must carry WellKnownDiagnosticTags.CompilationEnd. Beyond silencing RS1037, the tag tells the IDE this is a whole-compilation (cross-file) diagnostic surfaced at build / full-solution analysis rather than live per file. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- FirstClassErrors.Analyzers/Descriptors.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index a9a68a6..dc57901 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -16,7 +16,8 @@ internal static class Descriptors { 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)); + helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateErrorCode), + customTags: new[] { WellKnownDiagnosticTags.CompilationEnd }); public static readonly DiagnosticDescriptor EmptyErrorCode = new( id: DiagnosticIds.EmptyErrorCode, From fe13baf310cee0ef8a50260c9618a900c9c052c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:44:39 +0000 Subject: [PATCH 19/32] feat(analyzers): add FCE011 DuplicateDocumentedCode Report 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. Complements FCE001, which only sees duplicate ErrorCode.Create literals (a shared field has just one). Aggregates per code field across the compilation (operation-block collect -> compilation-end report) and carries the CompilationEnd tag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce011DuplicateDocumentedCodeTests.cs | 104 ++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 11 ++ .../DuplicateDocumentedCodeAnalyzer.cs | 111 ++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce011DuplicateDocumentedCodeTests.cs create mode 100644 FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 47bdd3b..02cb61b 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -12,5 +12,6 @@ FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidS 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 FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index dc57901..1f5306b 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -99,4 +99,15 @@ internal static class Descriptors { 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 }); + } diff --git a/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs new file mode 100644 index 0000000..b62ef33 --- /dev/null +++ b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs @@ -0,0 +1,111 @@ +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 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 + } + } + } + + // Pre-order (ancestor-before-descendant) walk over the operation tree; the outermost Error.Create is reached + // before any inner-error Create it may wrap. + private 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); + } + } + } + + 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)); + } + } + } + +} From 6b9bac398957700cc935135cb9decc007d4cdf60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:49:57 +0000 Subject: [PATCH 20/32] feat(analyzers): add FCE013 ExampleDoesNotCallDocumentedFactory Report an example passed to WithExamples(...) that invokes no factory of the type declaring the documentation. Examples exist to expose the documented error's real messages, so each should build that error. Lambda and method-group examples are inspected; unrecognized shapes are left alone to avoid false positives. Extracts the operation-tree walk into a shared OperationFacts helper, reused by FCE011. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...xampleDoesNotCallDocumentedFactoryTests.cs | 75 ++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ .../DuplicateDocumentedCodeAnalyzer.cs | 18 +--- ...pleDoesNotCallDocumentedFactoryAnalyzer.cs | 85 +++++++++++++++++++ FirstClassErrors.Analyzers/OperationFacts.cs | 27 ++++++ 6 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce013ExampleDoesNotCallDocumentedFactoryTests.cs create mode 100644 FirstClassErrors.Analyzers/ExampleDoesNotCallDocumentedFactoryAnalyzer.cs create mode 100644 FirstClassErrors.Analyzers/OperationFacts.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 02cb61b..7576067 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -14,4 +14,5 @@ FCE009 | FirstClassErrors.DocumentationWiring | Warning | ErrorFactoryNotDocum FCE010 | FirstClassErrors.DocumentationWiring | Warning | MultipleFactoriesShareDocumentationAnalyzer FCE011 | FirstClassErrors.DocumentationContent| Error | DuplicateDocumentedCodeAnalyzer FCE012 | FirstClassErrors.DocumentationContent| Warning | EmptyExamplesAnalyzer +FCE013 | FirstClassErrors.DocumentationContent| Warning | ExampleDoesNotCallDocumentedFactoryAnalyzer FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 1f5306b..c5bc192 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -110,4 +110,14 @@ internal static class Descriptors { 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)); + } diff --git a/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs index b62ef33..5bc4139 100644 --- a/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs +++ b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs @@ -48,7 +48,7 @@ private static void Collect( if (!SymbolFacts.HasAttribute(method, symbols.DocumentedByAttribute!)) { return; } foreach (IOperation block in context.OperationBlocks) { - foreach (IOperation operation in EnumerateOperations(block)) { + 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; } @@ -64,22 +64,6 @@ private static void Collect( } } - // Pre-order (ancestor-before-descendant) walk over the operation tree; the outermost Error.Create is reached - // before any inner-error Create it may wrap. - private 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); - } - } - } - private static bool TryGetCodeField(IOperation codeArgument, out ISymbol? codeField) { IOperation value = codeArgument is IConversionOperation conversion ? conversion.Operand : codeArgument; 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/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); + } + } + } + +} From ef8557a936a90c63d0fd16092e81044e7e626d7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:57:59 +0000 Subject: [PATCH 21/32] feat(analyzers): add FCE003 NonLiteralErrorCode Report ErrorCode.Create(x) where the argument is not a compile-time constant; such a code is invisible to FCE001 duplicate detection. Opt-in (disabled by default) for teams that want codes to stay literal. Also teaches the test harness to force opt-in rules on for a test run, the way an .editorconfig severity entry would. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../AnalyzerTestHarness.cs | 16 ++++- .../Fce003NonLiteralErrorCodeTests.cs | 61 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ .../NonLiteralErrorCodeAnalyzer.cs | 51 ++++++++++++++++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce003NonLiteralErrorCodeTests.cs create mode 100644 FirstClassErrors.Analyzers/NonLiteralErrorCodeAnalyzer.cs diff --git a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs index ff9c0b3..c6f5af3 100644 --- a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs +++ b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -17,14 +17,26 @@ internal static class AnalyzerTestHarness { private static readonly ImmutableArray BaseReferences = BuildBaseReferences(); - public static async Task> GetDiagnosticsAsync(DiagnosticAnalyzer analyzer, string source) { + 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: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + options: options); CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 7576067..5f9e44e 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,6 +7,7 @@ Rule ID | Category | Severity | Notes --------|--------------------------------------|----------|------------------------------------- FCE001 | FirstClassErrors.ErrorCodes | Error | DuplicateErrorCodeAnalyzer FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer +FCE003 | FirstClassErrors.ErrorCodes | Info | NonLiteralErrorCodeAnalyzer (disabled by default) FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index c5bc192..a96e637 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -29,6 +29,16 @@ internal static class Descriptors { 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 DocumentedByTargetNotFound = new( id: DiagnosticIds.DocumentedByTargetNotFound, title: "Documentation method referenced by [DocumentedBy] was not found", 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())); + } + +} From 004ead8f5bec3b420b50ac32c98ca284b5a09f29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:58:44 +0000 Subject: [PATCH 22/32] feat(analyzers): add FCE004 InvalidErrorCodeFormat Report a literal error code that does not follow the UPPER_SNAKE_CASE convention. Convention check, opt-in (disabled by default). Empty codes stay FCE002's concern and non-literal codes FCE003's. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce004InvalidErrorCodeFormatTests.cs | 44 +++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 ++++ .../InvalidErrorCodeFormatAnalyzer.cs | 56 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce004InvalidErrorCodeFormatTests.cs create mode 100644 FirstClassErrors.Analyzers/InvalidErrorCodeFormatAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 5f9e44e..1a545d6 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -8,6 +8,7 @@ Rule ID | Category | Severity | Notes FCE001 | FirstClassErrors.ErrorCodes | Error | DuplicateErrorCodeAnalyzer FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer FCE003 | FirstClassErrors.ErrorCodes | Info | NonLiteralErrorCodeAnalyzer (disabled by default) +FCE004 | FirstClassErrors.ErrorCodes | Info | InvalidErrorCodeFormatAnalyzer (disabled by default) FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index a96e637..5680813 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -39,6 +39,16 @@ internal static class Descriptors { 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 DocumentedByTargetNotFound = new( id: DiagnosticIds.DocumentedByTargetNotFound, title: "Documentation method referenced by [DocumentedBy] was not found", 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)); + } + +} From 6d80c43b5e04157b0bcc3bd242c25b6081b0981a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:59:28 +0000 Subject: [PATCH 23/32] feat(analyzers): add FCE005 TooGenericErrorCode Report a literal error code that is one of a small denylist of catch-all words (ERROR, INVALID, FAILED, ...) which carry no diagnostic value. Opt-in (disabled by default). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- .../Fce005TooGenericErrorCodeTests.cs | 44 +++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 ++++ .../TooGenericErrorCodeAnalyzer.cs | 56 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce005TooGenericErrorCodeTests.cs create mode 100644 FirstClassErrors.Analyzers/TooGenericErrorCodeAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 1a545d6..a8a05ac 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -9,6 +9,7 @@ FCE001 | FirstClassErrors.ErrorCodes | Error | DuplicateErrorCodeAn FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer FCE003 | FirstClassErrors.ErrorCodes | Info | NonLiteralErrorCodeAnalyzer (disabled by default) FCE004 | FirstClassErrors.ErrorCodes | Info | InvalidErrorCodeFormatAnalyzer (disabled by default) +FCE005 | FirstClassErrors.ErrorCodes | Info | TooGenericErrorCodeAnalyzer (disabled by default) FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 5680813..d0d000a 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -49,6 +49,16 @@ internal static class Descriptors { 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", 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)); + } + +} From 33f2b6c0380c98d5a9cc87d3fa834786017363cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:00:19 +0000 Subject: [PATCH 24/32] feat(analyzers): add FCE014 ShortMessageSameAsDetailedMessage Report WithPublicMessage(short, detailed) where both literal messages are equal. The short message is a public summary and the detailed one an optional public detail, so identical values usually signal a copy-paste. Info, enabled by default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...4ShortMessageSameAsDetailedMessageTests.cs | 62 +++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++ ...ortMessageSameAsDetailedMessageAnalyzer.cs | 53 ++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce014ShortMessageSameAsDetailedMessageTests.cs create mode 100644 FirstClassErrors.Analyzers/ShortMessageSameAsDetailedMessageAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index a8a05ac..1c06bcc 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -18,4 +18,5 @@ FCE010 | FirstClassErrors.DocumentationWiring | Warning | MultipleFactoriesSha FCE011 | FirstClassErrors.DocumentationContent| Error | DuplicateDocumentedCodeAnalyzer FCE012 | FirstClassErrors.DocumentationContent| Warning | EmptyExamplesAnalyzer FCE013 | FirstClassErrors.DocumentationContent| Warning | ExampleDoesNotCallDocumentedFactoryAnalyzer +FCE014 | FirstClassErrors.DocumentationContent| Info | ShortMessageSameAsDetailedMessageAnalyzer FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index d0d000a..f97497c 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -150,4 +150,14 @@ internal static class Descriptors { 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)); + } 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())); + } + +} From 60c027495ed52dfaf11feee3b82122d063fe82b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:01:11 +0000 Subject: [PATCH 25/32] feat(analyzers): add FCE015 DocumentationTitleTooGeneric Report a WithTitle("...") whose literal title is one of a small denylist of empty phrases (Error, Invalid value, Failure, ...). A good title names the condition. Opt-in (disabled by default). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- ...Fce015DocumentationTitleTooGenericTests.cs | 46 ++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + FirstClassErrors.Analyzers/Descriptors.cs | 10 +++- .../DocumentationTitleTooGenericAnalyzer.cs | 55 +++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 FirstClassErrors.Analyzers.UnitTests/Fce015DocumentationTitleTooGenericTests.cs create mode 100644 FirstClassErrors.Analyzers/DocumentationTitleTooGenericAnalyzer.cs 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/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 1c06bcc..91b5395 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -19,4 +19,5 @@ FCE011 | FirstClassErrors.DocumentationContent| Error | DuplicateDocumentedC FCE012 | FirstClassErrors.DocumentationContent| Warning | EmptyExamplesAnalyzer FCE013 | FirstClassErrors.DocumentationContent| Warning | ExampleDoesNotCallDocumentedFactoryAnalyzer FCE014 | FirstClassErrors.DocumentationContent| Info | ShortMessageSameAsDetailedMessageAnalyzer +FCE015 | FirstClassErrors.DocumentationContent| Info | DocumentationTitleTooGenericAnalyzer (disabled by default) FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index f97497c..124e9dc 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -160,4 +160,12 @@ internal static class Descriptors { 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/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)); + } + +} From c3f0341a10b9e5a8e8c14c2573e8ca30983f4942 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:07:29 +0000 Subject: [PATCH 26/32] fix(analyzers): close the Descriptors class (brace dropped in FCE015) The FCE015 edit replaced the class-closing brace without re-adding it, leaving Descriptors unclosed (CS1513). Restores the brace. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- FirstClassErrors.Analyzers/Descriptors.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs index 124e9dc..bc0540f 100644 --- a/FirstClassErrors.Analyzers/Descriptors.cs +++ b/FirstClassErrors.Analyzers/Descriptors.cs @@ -169,3 +169,5 @@ internal static class Descriptors { 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)); + +} From 34737f8873e6196620cd571851ca6880b8270a48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:34:59 +0000 Subject: [PATCH 27/32] fix(analyzers): mark opt-in rules as Disabled in release tracking (RS2001) FCE003/004/005/015 ship with isEnabledByDefault: false. The analyzer release file must record their Severity as "Disabled" (the effective severity of a disabled-by-default rule), not their nominal Info; otherwise RS2001 flags a category/severity mismatch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md index 91b5395..5998171 100644 --- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,9 +7,9 @@ Rule ID | Category | Severity | Notes --------|--------------------------------------|----------|------------------------------------- FCE001 | FirstClassErrors.ErrorCodes | Error | DuplicateErrorCodeAnalyzer FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer -FCE003 | FirstClassErrors.ErrorCodes | Info | NonLiteralErrorCodeAnalyzer (disabled by default) -FCE004 | FirstClassErrors.ErrorCodes | Info | InvalidErrorCodeFormatAnalyzer (disabled by default) -FCE005 | FirstClassErrors.ErrorCodes | Info | TooGenericErrorCodeAnalyzer (disabled by default) +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 @@ -19,5 +19,5 @@ FCE011 | FirstClassErrors.DocumentationContent| Error | DuplicateDocumentedC FCE012 | FirstClassErrors.DocumentationContent| Warning | EmptyExamplesAnalyzer FCE013 | FirstClassErrors.DocumentationContent| Warning | ExampleDoesNotCallDocumentedFactoryAnalyzer FCE014 | FirstClassErrors.DocumentationContent| Info | ShortMessageSameAsDetailedMessageAnalyzer -FCE015 | FirstClassErrors.DocumentationContent| Info | DocumentationTitleTooGenericAnalyzer (disabled by default) +FCE015 | FirstClassErrors.DocumentationContent| Disabled | DocumentationTitleTooGenericAnalyzer FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer From 0c7d8db24558e6c6b6463b7fd82fbb91cf837583 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:42:27 +0000 Subject: [PATCH 28/32] build(analyzers): surface analyzer-dev (RS) rules in CI via .editorconfig Raise the RS* analyzer-development rule categories to warning for the analyzer project so the command-line build (CI) flags the same issues an IDE does, instead of leaving them for a human to notice. Localization rules are left at defaults. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- FirstClassErrors.Analyzers/.editorconfig | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 FirstClassErrors.Analyzers/.editorconfig diff --git a/FirstClassErrors.Analyzers/.editorconfig b/FirstClassErrors.Analyzers/.editorconfig new file mode 100644 index 0000000..78a27ad --- /dev/null +++ b/FirstClassErrors.Analyzers/.editorconfig @@ -0,0 +1,10 @@ +# Scoped to the analyzer project. Raise the analyzer-development (RS*) rule categories to `warning` so the +# command-line build (and therefore CI) surfaces the same issues an IDE flags. The Localization category is left at +# its defaults on purpose: FirstClassErrors localizes its error catalog, but the analyzer's own diagnostic messages +# do not need to go through .resx. + +[*.cs] +dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisCorrectness.severity = warning +dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisDesign.severity = warning +dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisPerformance.severity = warning +dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisReleaseTracking.severity = warning From 26e70cd7f3ad860c0e0ef5c73666b8497281f0bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:49:07 +0000 Subject: [PATCH 29/32] build(analyzers): drop the RS .editorconfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising the analyzer-development (RS*) categories surfaced nothing in the command-line build (the CI toolchain's Microsoft.CodeAnalysis.Analyzers does not carry those rules at build severity), so it did not achieve the goal of mirroring the IDE's checks — and, since editorconfig also applies in the IDE, it would only risk adding noise there. The analyzer already follows the standard RS practices (CompilationEnd tags, release tracking, concurrent execution, help links). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- FirstClassErrors.Analyzers/.editorconfig | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 FirstClassErrors.Analyzers/.editorconfig diff --git a/FirstClassErrors.Analyzers/.editorconfig b/FirstClassErrors.Analyzers/.editorconfig deleted file mode 100644 index 78a27ad..0000000 --- a/FirstClassErrors.Analyzers/.editorconfig +++ /dev/null @@ -1,10 +0,0 @@ -# Scoped to the analyzer project. Raise the analyzer-development (RS*) rule categories to `warning` so the -# command-line build (and therefore CI) surfaces the same issues an IDE flags. The Localization category is left at -# its defaults on purpose: FirstClassErrors localizes its error catalog, but the analyzer's own diagnostic messages -# do not need to go through .resx. - -[*.cs] -dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisCorrectness.severity = warning -dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisDesign.severity = warning -dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisPerformance.severity = warning -dotnet_analyzer_diagnostic.category-MicrosoftCodeAnalysisReleaseTracking.severity = warning From 48a5379982e0d308c5f80d12b6df412a7917a384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 18:49:07 +0000 Subject: [PATCH 30/32] docs(analyzers): add per-rule reference pages and index Add doc/analyzers/FCE001..FCE016.md (the helpLinkUri targets): each page gives the rule's category, severity, default state, a noncompliant/compliant example, details/limitations, and how to enable the opt-in rules. Add doc/analyzers/README.md as the grouped index and link it from the main README. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- README.md | 7 +++++ doc/analyzers/FCE001.md | 33 +++++++++++++++++++++++ doc/analyzers/FCE002.md | 31 ++++++++++++++++++++++ doc/analyzers/FCE003.md | 36 +++++++++++++++++++++++++ doc/analyzers/FCE004.md | 39 +++++++++++++++++++++++++++ doc/analyzers/FCE005.md | 35 ++++++++++++++++++++++++ doc/analyzers/FCE006.md | 38 ++++++++++++++++++++++++++ doc/analyzers/FCE007.md | 35 ++++++++++++++++++++++++ doc/analyzers/FCE008.md | 38 ++++++++++++++++++++++++++ doc/analyzers/FCE009.md | 35 ++++++++++++++++++++++++ doc/analyzers/FCE010.md | 35 ++++++++++++++++++++++++ doc/analyzers/FCE011.md | 35 ++++++++++++++++++++++++ doc/analyzers/FCE012.md | 29 ++++++++++++++++++++ doc/analyzers/FCE013.md | 32 ++++++++++++++++++++++ doc/analyzers/FCE014.md | 25 +++++++++++++++++ doc/analyzers/FCE015.md | 35 ++++++++++++++++++++++++ doc/analyzers/FCE016.md | 29 ++++++++++++++++++++ doc/analyzers/README.md | 59 +++++++++++++++++++++++++++++++++++++++++ 18 files changed, 606 insertions(+) create mode 100644 doc/analyzers/FCE001.md create mode 100644 doc/analyzers/FCE002.md create mode 100644 doc/analyzers/FCE003.md create mode 100644 doc/analyzers/FCE004.md create mode 100644 doc/analyzers/FCE005.md create mode 100644 doc/analyzers/FCE006.md create mode 100644 doc/analyzers/FCE007.md create mode 100644 doc/analyzers/FCE008.md create mode 100644 doc/analyzers/FCE009.md create mode 100644 doc/analyzers/FCE010.md create mode 100644 doc/analyzers/FCE011.md create mode 100644 doc/analyzers/FCE012.md create mode 100644 doc/analyzers/FCE013.md create mode 100644 doc/analyzers/FCE014.md create mode 100644 doc/analyzers/FCE015.md create mode 100644 doc/analyzers/FCE016.md create mode 100644 doc/analyzers/README.md 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/analyzers/FCE001.md b/doc/analyzers/FCE001.md new file mode 100644 index 0000000..a1a82a1 --- /dev/null +++ b/doc/analyzers/FCE001.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.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.md), [FCE003](FCE003.md), [FCE011](FCE011.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE002.md b/doc/analyzers/FCE002.md new file mode 100644 index 0000000..1bd39db --- /dev/null +++ b/doc/analyzers/FCE002.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.md)). + +**Related:** [FCE001](FCE001.md), [FCE003](FCE003.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE003.md b/doc/analyzers/FCE003.md new file mode 100644 index 0000000..f21d5ce --- /dev/null +++ b/doc/analyzers/FCE003.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.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.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE004.md b/doc/analyzers/FCE004.md new file mode 100644 index 0000000..09b23fb --- /dev/null +++ b/doc/analyzers/FCE004.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.md) and non-literal codes by [FCE003](FCE003.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.md), [FCE005](FCE005.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE005.md b/doc/analyzers/FCE005.md new file mode 100644 index 0000000..699a46d --- /dev/null +++ b/doc/analyzers/FCE005.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.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE006.md b/doc/analyzers/FCE006.md new file mode 100644 index 0000000..1901536 --- /dev/null +++ b/doc/analyzers/FCE006.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.md), [FCE008](FCE008.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE007.md b/doc/analyzers/FCE007.md new file mode 100644 index 0000000..9793d65 --- /dev/null +++ b/doc/analyzers/FCE007.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.md). + +**Related:** [FCE006](FCE006.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE008.md b/doc/analyzers/FCE008.md new file mode 100644 index 0000000..c7a6c72 --- /dev/null +++ b/doc/analyzers/FCE008.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.md), [FCE009](FCE009.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE009.md b/doc/analyzers/FCE009.md new file mode 100644 index 0000000..f64b816 --- /dev/null +++ b/doc/analyzers/FCE009.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.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE010.md b/doc/analyzers/FCE010.md new file mode 100644 index 0000000..f6c0380 --- /dev/null +++ b/doc/analyzers/FCE010.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.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE011.md b/doc/analyzers/FCE011.md new file mode 100644 index 0000000..178a5e7 --- /dev/null +++ b/doc/analyzers/FCE011.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.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.md), [FCE010](FCE010.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE012.md b/doc/analyzers/FCE012.md new file mode 100644 index 0000000..606afa3 --- /dev/null +++ b/doc/analyzers/FCE012.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.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE013.md b/doc/analyzers/FCE013.md new file mode 100644 index 0000000..837fb5c --- /dev/null +++ b/doc/analyzers/FCE013.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.md) + +--- + +[← All FirstClassErrors analyzer rules](README.md) diff --git a/doc/analyzers/FCE014.md b/doc/analyzers/FCE014.md new file mode 100644 index 0000000..afad89c --- /dev/null +++ b/doc/analyzers/FCE014.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/FCE015.md b/doc/analyzers/FCE015.md new file mode 100644 index 0000000..d3627e0 --- /dev/null +++ b/doc/analyzers/FCE015.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/FCE016.md b/doc/analyzers/FCE016.md new file mode 100644 index 0000000..0031070 --- /dev/null +++ b/doc/analyzers/FCE016.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/README.md b/doc/analyzers/README.md new file mode 100644 index 0000000..8e6a127 --- /dev/null +++ b/doc/analyzers/README.md @@ -0,0 +1,59 @@ +# FirstClassErrors analyzers + +Roslyn analyzers that catch, at build time, the mistakes the FirstClassErrors runtime and documentation +pipeline would otherwise only surface later (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.md) | 🔴 Error | on | The same literal error code is created by more than one ErrorCode.Create("...") in the compilation. | +| [FCE002 EmptyErrorCode](FCE002.md) | 🔴 Error | on | ErrorCode.Create is called with an empty, whitespace, or null literal. | +| [FCE003 NonLiteralErrorCode](FCE003.md) | 🔵 Info | opt-in | ErrorCode.Create is called with an argument that is not a compile-time constant. | +| [FCE004 InvalidErrorCodeFormat](FCE004.md) | 🔵 Info | opt-in | A literal error code does not follow the UPPER_SNAKE_CASE convention. | +| [FCE005 TooGenericErrorCode](FCE005.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.md) | 🔴 Error | on | A [DocumentedBy("...")] names a documentation method that does not exist on the containing type. | +| [FCE007 DocumentedByInvalidSignature](FCE007.md) | 🔴 Error | on | The method referenced by [DocumentedBy] exists but cannot be used as a documentation factory. | +| [FCE008 DocumentedByWithoutProvidesErrorsFor](FCE008.md) | 🔴 Error | on | A type declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]. | +| [FCE009 ErrorFactoryNotDocumented](FCE009.md) | 🟠 Warning | on | A non-private static factory that returns an Error in a [ProvidesErrorsFor] type carries no [DocumentedBy]. | +| [FCE010 MultipleFactoriesShareDocumentation](FCE010.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.md) | 🔴 Error | on | More than one documented factory produces the same error code by referencing the same ErrorCode field. | +| [FCE012 EmptyExamples](FCE012.md) | 🟠 Warning | on | The terminal WithExamples() call of the documentation DSL is given no example factory. | +| [FCE013 ExampleDoesNotCallDocumentedFactory](FCE013.md) | 🟠 Warning | on | An example passed to WithExamples(...) does not invoke any factory of the type that declares the documentation. | +| [FCE014 ShortMessageSameAsDetailedMessage](FCE014.md) | 🔵 Info | on | WithPublicMessage(short, detailed) is called with two identical literal messages. | +| [FCE015 DocumentationTitleTooGeneric](FCE015.md) | 🔵 Info | opt-in | A WithTitle("...") uses a title that describes nothing (Error, Invalid value, Failure, ...). | + +## Usage + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [FCE016 UnusedToExceptionResult](FCE016.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. From 2b0a47641c6483232f2711e0ad99da096bb00e96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 19:00:05 +0000 Subject: [PATCH 31/32] docs(analyzers): sync the French README with the analyzer section and link Mirror in doc/README.fr.md the analyzer section and reference link added to the English README, keeping the bilingual READMEs in step. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- doc/README.fr.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/README.fr.md b/doc/README.fr.md index 4991871..3ca6b6b 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.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.md) - [FAQ](FAQ.fr.md) From e99280945d9367063712639cb9e9c4d7c400524c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 19:17:57 +0000 Subject: [PATCH 32/32] docs(analyzers): make the rule pages bilingual (EN/FR) Follow the repository's bilingual convention for the analyzer reference docs: rename each rule page to FCExxx.en.md and add a French FCExxx.fr.md, with an English (README.md) and French (README.fr.md) index. Point the rules' help links at the .en.md pages and fix the French README links. Keeps EN and FR docs in step. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UXVAic46o24c1XAfiKEoE6 --- FirstClassErrors.Analyzers/HelpLinks.cs | 2 +- doc/README.fr.md | 4 +- doc/analyzers/{FCE001.md => FCE001.en.md} | 4 +- doc/analyzers/FCE001.fr.md | 33 ++++++++++++++ doc/analyzers/{FCE002.md => FCE002.en.md} | 4 +- doc/analyzers/FCE002.fr.md | 31 +++++++++++++ doc/analyzers/{FCE003.md => FCE003.en.md} | 4 +- doc/analyzers/FCE003.fr.md | 36 +++++++++++++++ doc/analyzers/{FCE004.md => FCE004.en.md} | 4 +- doc/analyzers/FCE004.fr.md | 39 ++++++++++++++++ doc/analyzers/{FCE005.md => FCE005.en.md} | 2 +- doc/analyzers/FCE005.fr.md | 35 +++++++++++++++ doc/analyzers/{FCE006.md => FCE006.en.md} | 2 +- doc/analyzers/FCE006.fr.md | 38 ++++++++++++++++ doc/analyzers/{FCE007.md => FCE007.en.md} | 4 +- doc/analyzers/FCE007.fr.md | 35 +++++++++++++++ doc/analyzers/{FCE008.md => FCE008.en.md} | 2 +- doc/analyzers/FCE008.fr.md | 38 ++++++++++++++++ doc/analyzers/{FCE009.md => FCE009.en.md} | 2 +- doc/analyzers/FCE009.fr.md | 35 +++++++++++++++ doc/analyzers/{FCE010.md => FCE010.en.md} | 2 +- doc/analyzers/FCE010.fr.md | 35 +++++++++++++++ doc/analyzers/{FCE011.md => FCE011.en.md} | 4 +- doc/analyzers/FCE011.fr.md | 35 +++++++++++++++ doc/analyzers/{FCE012.md => FCE012.en.md} | 2 +- doc/analyzers/FCE012.fr.md | 29 ++++++++++++ doc/analyzers/{FCE013.md => FCE013.en.md} | 2 +- doc/analyzers/FCE013.fr.md | 32 +++++++++++++ doc/analyzers/{FCE014.md => FCE014.en.md} | 0 doc/analyzers/FCE014.fr.md | 25 +++++++++++ doc/analyzers/{FCE015.md => FCE015.en.md} | 0 doc/analyzers/FCE015.fr.md | 35 +++++++++++++++ doc/analyzers/{FCE016.md => FCE016.en.md} | 0 doc/analyzers/FCE016.fr.md | 29 ++++++++++++ doc/analyzers/README.fr.md | 55 +++++++++++++++++++++++ doc/analyzers/README.md | 42 ++++++++--------- 36 files changed, 636 insertions(+), 45 deletions(-) rename doc/analyzers/{FCE001.md => FCE001.en.md} (84%) create mode 100644 doc/analyzers/FCE001.fr.md rename doc/analyzers/{FCE002.md => FCE002.en.md} (86%) create mode 100644 doc/analyzers/FCE002.fr.md rename doc/analyzers/{FCE003.md => FCE003.en.md} (83%) create mode 100644 doc/analyzers/FCE003.fr.md rename doc/analyzers/{FCE004.md => FCE004.en.md} (84%) create mode 100644 doc/analyzers/FCE004.fr.md rename doc/analyzers/{FCE005.md => FCE005.en.md} (95%) create mode 100644 doc/analyzers/FCE005.fr.md rename doc/analyzers/{FCE006.md => FCE006.en.md} (94%) create mode 100644 doc/analyzers/FCE006.fr.md rename doc/analyzers/{FCE007.md => FCE007.en.md} (95%) create mode 100644 doc/analyzers/FCE007.fr.md rename doc/analyzers/{FCE008.md => FCE008.en.md} (94%) create mode 100644 doc/analyzers/FCE008.fr.md rename doc/analyzers/{FCE009.md => FCE009.en.md} (95%) create mode 100644 doc/analyzers/FCE009.fr.md rename doc/analyzers/{FCE010.md => FCE010.en.md} (96%) create mode 100644 doc/analyzers/FCE010.fr.md rename doc/analyzers/{FCE011.md => FCE011.en.md} (82%) create mode 100644 doc/analyzers/FCE011.fr.md rename doc/analyzers/{FCE012.md => FCE012.en.md} (95%) create mode 100644 doc/analyzers/FCE012.fr.md rename doc/analyzers/{FCE013.md => FCE013.en.md} (96%) create mode 100644 doc/analyzers/FCE013.fr.md rename doc/analyzers/{FCE014.md => FCE014.en.md} (100%) create mode 100644 doc/analyzers/FCE014.fr.md rename doc/analyzers/{FCE015.md => FCE015.en.md} (100%) create mode 100644 doc/analyzers/FCE015.fr.md rename doc/analyzers/{FCE016.md => FCE016.en.md} (100%) create mode 100644 doc/analyzers/FCE016.fr.md create mode 100644 doc/analyzers/README.fr.md diff --git a/FirstClassErrors.Analyzers/HelpLinks.cs b/FirstClassErrors.Analyzers/HelpLinks.cs index 8a59e71..651f3a4 100644 --- a/FirstClassErrors.Analyzers/HelpLinks.cs +++ b/FirstClassErrors.Analyzers/HelpLinks.cs @@ -9,7 +9,7 @@ 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}.md"; + return $"{Base}/{diagnosticId}.en.md"; } } diff --git a/doc/README.fr.md b/doc/README.fr.md index 3ca6b6b..504706e 100644 --- a/doc/README.fr.md +++ b/doc/README.fr.md @@ -197,7 +197,7 @@ FirstClassErrors est particulièrement utile si : 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.md). +Voir la [référence des règles d’analyse](analyzers/README.fr.md). ## 📚 Étapes suivantes @@ -216,5 +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.md) +- [Règles d’analyse (FCExxx)](analyzers/README.fr.md) - [FAQ](FAQ.fr.md) diff --git a/doc/analyzers/FCE001.md b/doc/analyzers/FCE001.en.md similarity index 84% rename from doc/analyzers/FCE001.md rename to doc/analyzers/FCE001.en.md index a1a82a1..65c1196 100644 --- a/doc/analyzers/FCE001.md +++ b/doc/analyzers/FCE001.en.md @@ -24,9 +24,9 @@ public static readonly ErrorCode B = ErrorCode.Create("MONEY_TRANSFER_AMOUNT_NOT ## 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.md)). This is a compilation-end diagnostic: it appears at build / full-solution analysis, not necessarily as you type in a single file. +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.md), [FCE003](FCE003.md), [FCE011](FCE011.md) +**Related:** [FCE002](FCE002.en.md), [FCE003](FCE003.en.md), [FCE011](FCE011.en.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.md b/doc/analyzers/FCE002.en.md similarity index 86% rename from doc/analyzers/FCE002.md rename to doc/analyzers/FCE002.en.md index 1bd39db..7f91782 100644 --- a/doc/analyzers/FCE002.md +++ b/doc/analyzers/FCE002.en.md @@ -22,9 +22,9 @@ 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.md)). +Only literal arguments are inspected. A code computed at run time is out of scope (see [FCE003](FCE003.en.md)). -**Related:** [FCE001](FCE001.md), [FCE003](FCE003.md) +**Related:** [FCE001](FCE001.en.md), [FCE003](FCE003.en.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.md b/doc/analyzers/FCE003.en.md similarity index 83% rename from doc/analyzers/FCE003.md rename to doc/analyzers/FCE003.en.md index f21d5ce..b739ae9 100644 --- a/doc/analyzers/FCE003.md +++ b/doc/analyzers/FCE003.en.md @@ -6,7 +6,7 @@ | **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.md)) and format checks. Enable this rule if your team wants every error code to stay literal. +`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 @@ -29,7 +29,7 @@ This rule is disabled by default. Turn it on in `.editorconfig`: dotnet_diagnostic.FCE003.severity = suggestion # or warning / error ``` -**Related:** [FCE001](FCE001.md) +**Related:** [FCE001](FCE001.en.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.md b/doc/analyzers/FCE004.en.md similarity index 84% rename from doc/analyzers/FCE004.md rename to doc/analyzers/FCE004.en.md index 09b23fb..2c9eae3 100644 --- a/doc/analyzers/FCE004.md +++ b/doc/analyzers/FCE004.en.md @@ -22,7 +22,7 @@ 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.md) and non-literal codes by [FCE003](FCE003.md). +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 @@ -32,7 +32,7 @@ This rule is disabled by default. Turn it on in `.editorconfig`: dotnet_diagnostic.FCE004.severity = suggestion # or warning / error ``` -**Related:** [FCE002](FCE002.md), [FCE005](FCE005.md) +**Related:** [FCE002](FCE002.en.md), [FCE005](FCE005.en.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.md b/doc/analyzers/FCE005.en.md similarity index 95% rename from doc/analyzers/FCE005.md rename to doc/analyzers/FCE005.en.md index 699a46d..08db691 100644 --- a/doc/analyzers/FCE005.md +++ b/doc/analyzers/FCE005.en.md @@ -28,7 +28,7 @@ This rule is disabled by default. Turn it on in `.editorconfig`: dotnet_diagnostic.FCE005.severity = suggestion # or warning / error ``` -**Related:** [FCE004](FCE004.md) +**Related:** [FCE004](FCE004.en.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.md b/doc/analyzers/FCE006.en.md similarity index 94% rename from doc/analyzers/FCE006.md rename to doc/analyzers/FCE006.en.md index 1901536..f57f9c2 100644 --- a/doc/analyzers/FCE006.md +++ b/doc/analyzers/FCE006.en.md @@ -31,7 +31,7 @@ internal static DomainError BelowAbsoluteZero() => /* ... */; Prefer `nameof(...)` over a string literal so the compiler itself catches a missing target. -**Related:** [FCE007](FCE007.md), [FCE008](FCE008.md) +**Related:** [FCE007](FCE007.en.md), [FCE008](FCE008.en.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.md b/doc/analyzers/FCE007.en.md similarity index 95% rename from doc/analyzers/FCE007.md rename to doc/analyzers/FCE007.en.md index 9793d65..2aa838f 100644 --- a/doc/analyzers/FCE007.md +++ b/doc/analyzers/FCE007.en.md @@ -26,9 +26,9 @@ private static ErrorDocumentation Doc() => ## Details -A missing target is not this rule's concern; see [FCE006](FCE006.md). +A missing target is not this rule's concern; see [FCE006](FCE006.en.md). -**Related:** [FCE006](FCE006.md) +**Related:** [FCE006](FCE006.en.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.md b/doc/analyzers/FCE008.en.md similarity index 94% rename from doc/analyzers/FCE008.md rename to doc/analyzers/FCE008.en.md index c7a6c72..d438e83 100644 --- a/doc/analyzers/FCE008.md +++ b/doc/analyzers/FCE008.en.md @@ -31,7 +31,7 @@ public static class SampleError { } ``` -**Related:** [FCE006](FCE006.md), [FCE009](FCE009.md) +**Related:** [FCE006](FCE006.en.md), [FCE009](FCE009.en.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.md b/doc/analyzers/FCE009.en.md similarity index 95% rename from doc/analyzers/FCE009.md rename to doc/analyzers/FCE009.en.md index f64b816..57ddb95 100644 --- a/doc/analyzers/FCE009.md +++ b/doc/analyzers/FCE009.en.md @@ -28,7 +28,7 @@ internal static DomainError Boom() => /* ... */; Private methods are treated as helpers and left alone. -**Related:** [FCE008](FCE008.md) +**Related:** [FCE008](FCE008.en.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.md b/doc/analyzers/FCE010.en.md similarity index 96% rename from doc/analyzers/FCE010.md rename to doc/analyzers/FCE010.en.md index f6c0380..91479e5 100644 --- a/doc/analyzers/FCE010.md +++ b/doc/analyzers/FCE010.en.md @@ -28,7 +28,7 @@ internal static DomainError A() => /* ... */; internal static DomainError B() => /* ... */; ``` -**Related:** [FCE011](FCE011.md) +**Related:** [FCE011](FCE011.en.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.md b/doc/analyzers/FCE011.en.md similarity index 82% rename from doc/analyzers/FCE011.md rename to doc/analyzers/FCE011.en.md index 178a5e7..1467795 100644 --- a/doc/analyzers/FCE011.md +++ b/doc/analyzers/FCE011.en.md @@ -6,7 +6,7 @@ | **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.md), which only sees duplicate `ErrorCode.Create` literals (a shared field has just one). +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 @@ -28,7 +28,7 @@ internal static DomainError B() => DomainError.Create(Code.Shared, "...").WithPu This is a compilation-end diagnostic: it appears at build / full-solution analysis. -**Related:** [FCE001](FCE001.md), [FCE010](FCE010.md) +**Related:** [FCE001](FCE001.en.md), [FCE010](FCE010.en.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.md b/doc/analyzers/FCE012.en.md similarity index 95% rename from doc/analyzers/FCE012.md rename to doc/analyzers/FCE012.en.md index 606afa3..74fa03c 100644 --- a/doc/analyzers/FCE012.md +++ b/doc/analyzers/FCE012.en.md @@ -22,7 +22,7 @@ DescribeError.WithTitle("...").WithDescription("...").WithoutRule().WithoutDiagn .WithExamples(() => Boom()); ``` -**Related:** [FCE013](FCE013.md) +**Related:** [FCE013](FCE013.en.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.md b/doc/analyzers/FCE013.en.md similarity index 96% rename from doc/analyzers/FCE013.md rename to doc/analyzers/FCE013.en.md index 837fb5c..a705b44 100644 --- a/doc/analyzers/FCE013.md +++ b/doc/analyzers/FCE013.en.md @@ -25,7 +25,7 @@ An example passed to `WithExamples(...)` does not invoke any factory of the type Recognized example shapes are lambdas and method groups. An unrecognized shape is left alone to avoid false positives. -**Related:** [FCE012](FCE012.md) +**Related:** [FCE012](FCE012.en.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.md b/doc/analyzers/FCE014.en.md similarity index 100% rename from doc/analyzers/FCE014.md rename to doc/analyzers/FCE014.en.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.md b/doc/analyzers/FCE015.en.md similarity index 100% rename from doc/analyzers/FCE015.md rename to doc/analyzers/FCE015.en.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.md b/doc/analyzers/FCE016.en.md similarity index 100% rename from doc/analyzers/FCE016.md rename to doc/analyzers/FCE016.en.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 index 8e6a127..d1d0650 100644 --- a/doc/analyzers/README.md +++ b/doc/analyzers/README.md @@ -1,47 +1,44 @@ # FirstClassErrors analyzers -Roslyn analyzers that catch, at build time, the mistakes the FirstClassErrors runtime and documentation -pipeline would otherwise only surface later (or silently). They ship **inside the `FirstClassErrors` NuGet -package** — any project that references it gets the rules automatically, no extra install. +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). +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.md) | 🔴 Error | on | The same literal error code is created by more than one ErrorCode.Create("...") in the compilation. | -| [FCE002 EmptyErrorCode](FCE002.md) | 🔴 Error | on | ErrorCode.Create is called with an empty, whitespace, or null literal. | -| [FCE003 NonLiteralErrorCode](FCE003.md) | 🔵 Info | opt-in | ErrorCode.Create is called with an argument that is not a compile-time constant. | -| [FCE004 InvalidErrorCodeFormat](FCE004.md) | 🔵 Info | opt-in | A literal error code does not follow the UPPER_SNAKE_CASE convention. | -| [FCE005 TooGenericErrorCode](FCE005.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. | +| [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.md) | 🔴 Error | on | A [DocumentedBy("...")] names a documentation method that does not exist on the containing type. | -| [FCE007 DocumentedByInvalidSignature](FCE007.md) | 🔴 Error | on | The method referenced by [DocumentedBy] exists but cannot be used as a documentation factory. | -| [FCE008 DocumentedByWithoutProvidesErrorsFor](FCE008.md) | 🔴 Error | on | A type declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]. | -| [FCE009 ErrorFactoryNotDocumented](FCE009.md) | 🟠 Warning | on | A non-private static factory that returns an Error in a [ProvidesErrorsFor] type carries no [DocumentedBy]. | -| [FCE010 MultipleFactoriesShareDocumentation](FCE010.md) | 🟠 Warning | on | Two or more factories in the same type point [DocumentedBy] at the same documentation method. | +| [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.md) | 🔴 Error | on | More than one documented factory produces the same error code by referencing the same ErrorCode field. | -| [FCE012 EmptyExamples](FCE012.md) | 🟠 Warning | on | The terminal WithExamples() call of the documentation DSL is given no example factory. | -| [FCE013 ExampleDoesNotCallDocumentedFactory](FCE013.md) | 🟠 Warning | on | An example passed to WithExamples(...) does not invoke any factory of the type that declares the documentation. | -| [FCE014 ShortMessageSameAsDetailedMessage](FCE014.md) | 🔵 Info | on | WithPublicMessage(short, detailed) is called with two identical literal messages. | -| [FCE015 DocumentationTitleTooGeneric](FCE015.md) | 🔵 Info | opt-in | A WithTitle("...") uses a title that describes nothing (Error, Invalid value, Failure, ...). | +| [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.md) | 🟠 Warning | on | Error.ToException() is called as a standalone statement and its result is discarded. | +| [FCE016 UnusedToExceptionResult](FCE016.en.md) | 🟠 Warning | on | Error.ToException() is called as a standalone statement and its result is discarded. | ## Configuring @@ -55,5 +52,4 @@ dotnet_diagnostic.FCE004.severity = warning 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. +> `FCE001` and `FCE011` are whole-compilation checks: they appear at build / full-solution analysis rather than as you type in a single file.