diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml
new file mode 100644
index 0000000..dbd4a0f
--- /dev/null
+++ b/.github/workflows/analyzers.yml
@@ -0,0 +1,38 @@
+name: analyzers
+
+on:
+ push:
+ branches:
+ - analyzers
+ - 'claude/**'
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ build-test:
+ name: Build & test analyzers
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore
+ run: dotnet restore FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj
+
+ - name: Build
+ run: dotnet build FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj -c Release --no-restore
+
+ - name: Test
+ run: dotnet test FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj -c Release --no-build --logger "console;verbosity=detailed"
+
+ # Dogfood: build the sample with the analyzers wired in. Fails on any Error-severity FCExxx and surfaces
+ # the warnings in the log. Doc generation is off here so this stays a pure analyzer check.
+ - name: Build usage (dogfood analyzers)
+ run: dotnet build FirstClassErrors.Usage/FirstClassErrors.Usage.csproj -c Release -p:GenerateErrorDocumentation=false
diff --git a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs
new file mode 100644
index 0000000..c6f5af3
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs
@@ -0,0 +1,66 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+///
+/// Minimal in-process harness: compiles a C# snippet against the running runtime plus the FirstClassErrors core,
+/// runs a single analyzer over it, and returns the analyzer diagnostics. Deliberately dependency-free (no
+/// Microsoft.CodeAnalysis.Testing) so it composes cleanly with xUnit v3 and NFluent.
+///
+internal static class AnalyzerTestHarness {
+
+ private static readonly ImmutableArray BaseReferences = BuildBaseReferences();
+
+ public static async Task> GetDiagnosticsAsync(
+ DiagnosticAnalyzer analyzer,
+ string source,
+ params string[] enabledDiagnosticIds) {
+
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source);
+
+ CSharpCompilationOptions options = new(OutputKind.DynamicallyLinkedLibrary);
+ if (enabledDiagnosticIds.Length > 0) {
+ // Force otherwise opt-in (isEnabledByDefault: false) rules on for the test, as an .editorconfig would.
+ ImmutableDictionary.Builder specific = ImmutableDictionary.CreateBuilder();
+ foreach (string id in enabledDiagnosticIds) { specific[id] = ReportDiagnostic.Info; }
+ options = options.WithSpecificDiagnosticOptions(specific.ToImmutable());
+ }
+
+ CSharpCompilation compilation = CSharpCompilation.Create(
+ assemblyName: "FirstClassErrors.Analyzers.TestSnippet",
+ syntaxTrees: new[] { syntaxTree },
+ references: BaseReferences,
+ options: options);
+
+ CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
+
+ return await withAnalyzers.GetAnalyzerDiagnosticsAsync();
+ }
+
+ private static ImmutableArray BuildBaseReferences() {
+ List references = new();
+
+ // Reference the running runtime's assemblies so snippets resolve System types without pinning a ref pack.
+ string trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? string.Empty;
+ foreach (string path in trustedAssemblies.Split(Path.PathSeparator)) {
+ if (string.IsNullOrEmpty(path)) { continue; }
+ try {
+ references.Add(MetadataReference.CreateFromFile(path));
+ } catch {
+ // Skip any native or otherwise unloadable entry in the TPA list.
+ }
+ }
+
+ // The FirstClassErrors core, so ErrorCode / DomainError / DescribeError resolve inside the snippet.
+ references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location));
+
+ return references.ToImmutableArray();
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce001DuplicateErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce001DuplicateErrorCodeTests.cs
new file mode 100644
index 0000000..c4b6598
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce001DuplicateErrorCodeTests.cs
@@ -0,0 +1,98 @@
+using System.Collections.Immutable;
+using System.Linq;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce001DuplicateErrorCodeTests {
+
+ [Fact]
+ public async Task Reports_each_site_when_same_code_is_created_twice() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class First {
+ public static readonly ErrorCode A = ErrorCode.Create("DUP");
+ }
+
+ public static class Second {
+ public static readonly ErrorCode B = ErrorCode.Create("DUP");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(2);
+ Check.That(diagnostics.All(d => d.Id == "FCE001")).IsTrue();
+ }
+
+ [Fact]
+ public async Task Does_not_report_for_distinct_codes() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("ONE");
+ public static readonly ErrorCode B = ErrorCode.Create("TWO");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_for_a_single_use() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("ONLY");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Ignores_duplicate_empty_codes_which_belong_to_FCE002() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("");
+ public static readonly ErrorCode B = ErrorCode.Create("");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Ignores_non_literal_codes() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ private static string Build() => "DYNAMIC";
+ public static readonly ErrorCode A = ErrorCode.Create(Build());
+ public static readonly ErrorCode B = ErrorCode.Create(Build());
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs
new file mode 100644
index 0000000..f90b180
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs
@@ -0,0 +1,76 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce002EmptyErrorCodeTests {
+
+ [Fact]
+ public async Task Reports_on_empty_string_literal() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static readonly ErrorCode Code = ErrorCode.Create("");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE002");
+ }
+
+ [Fact]
+ public async Task Reports_on_whitespace_string_literal() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static readonly ErrorCode Code = ErrorCode.Create(" ");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE002");
+ }
+
+ [Fact]
+ public async Task Does_not_report_on_valid_literal() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static readonly ErrorCode Code = ErrorCode.Create("VALID_CODE");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_on_non_literal_argument() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ private static string Build() => "DYNAMIC_CODE";
+ public static readonly ErrorCode Code = ErrorCode.Create(Build());
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce003NonLiteralErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce003NonLiteralErrorCodeTests.cs
new file mode 100644
index 0000000..6b850f3
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce003NonLiteralErrorCodeTests.cs
@@ -0,0 +1,61 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce003NonLiteralErrorCodeTests {
+
+ [Fact]
+ public async Task Reports_when_code_is_computed_at_runtime() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ private static string Build() => "DYNAMIC";
+ public static readonly ErrorCode A = ErrorCode.Create(Build());
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new NonLiteralErrorCodeAnalyzer(), source, "FCE003");
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE003");
+ }
+
+ [Fact]
+ public async Task Does_not_report_for_a_literal_code() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("LITERAL_CODE");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new NonLiteralErrorCodeAnalyzer(), source, "FCE003");
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_for_a_constant_reference() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ private const string Value = "CONST_CODE";
+ public static readonly ErrorCode A = ErrorCode.Create(Value);
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new NonLiteralErrorCodeAnalyzer(), source, "FCE003");
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce004InvalidErrorCodeFormatTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce004InvalidErrorCodeFormatTests.cs
new file mode 100644
index 0000000..cf61921
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce004InvalidErrorCodeFormatTests.cs
@@ -0,0 +1,44 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce004InvalidErrorCodeFormatTests {
+
+ [Fact]
+ public async Task Reports_when_code_is_not_upper_snake_case() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("moneyTransferInvalid");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InvalidErrorCodeFormatAnalyzer(), source, "FCE004");
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE004");
+ }
+
+ [Fact]
+ public async Task Does_not_report_for_upper_snake_case() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InvalidErrorCodeFormatAnalyzer(), source, "FCE004");
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce005TooGenericErrorCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce005TooGenericErrorCodeTests.cs
new file mode 100644
index 0000000..e8ad3ac
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce005TooGenericErrorCodeTests.cs
@@ -0,0 +1,44 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce005TooGenericErrorCodeTests {
+
+ [Fact]
+ public async Task Reports_a_generic_code() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("INVALID");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TooGenericErrorCodeAnalyzer(), source, "FCE005");
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE005");
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_specific_code() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Codes {
+ public static readonly ErrorCode A = ErrorCode.Create("MONEY_TRANSFER_INVALID");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TooGenericErrorCodeAnalyzer(), source, "FCE005");
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs
new file mode 100644
index 0000000..6c78631
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce006DocumentedByTargetNotFoundTests.cs
@@ -0,0 +1,70 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce006DocumentedByTargetNotFoundTests {
+
+ [Fact]
+ public async Task Reports_when_target_method_does_not_exist() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy("Nope")]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByTargetNotFoundAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE006");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_target_method_exists() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByTargetNotFoundAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_method_has_no_documented_by() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByTargetNotFoundAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs
new file mode 100644
index 0000000..0608e6c
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce007DocumentedByInvalidSignatureTests.cs
@@ -0,0 +1,95 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce007DocumentedByInvalidSignatureTests {
+
+ [Fact]
+ public async Task Reports_when_target_has_wrong_return_type() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static string Doc() => "not a documentation";
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE007");
+ }
+
+ [Fact]
+ public async Task Reports_when_target_is_not_static() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE007");
+ }
+
+ [Fact]
+ public async Task Does_not_report_for_valid_documentation_method() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_target_is_missing() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy("Nope")]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByInvalidSignatureAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs
new file mode 100644
index 0000000..bcd949e
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce008DocumentedByWithoutProvidesErrorsForTests.cs
@@ -0,0 +1,71 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce008DocumentedByWithoutProvidesErrorsForTests {
+
+ [Fact]
+ public async Task Reports_when_documented_type_lacks_provides_errors_for() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByWithoutProvidesErrorsForAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE008");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_provides_errors_for_is_present() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByWithoutProvidesErrorsForAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_type_has_no_documented_factory() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class SampleError {
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentedByWithoutProvidesErrorsForAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce009ErrorFactoryNotDocumentedTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce009ErrorFactoryNotDocumentedTests.cs
new file mode 100644
index 0000000..d375a2e
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce009ErrorFactoryNotDocumentedTests.cs
@@ -0,0 +1,85 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce009ErrorFactoryNotDocumentedTests {
+
+ [Fact]
+ public async Task Reports_undocumented_factory_in_provides_errors_for_type() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE009");
+ }
+
+ [Fact]
+ public async Task Does_not_report_documented_factory() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_type_is_not_a_provider() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class SampleError {
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_private_helper() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ private static DomainError Helper() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ErrorFactoryNotDocumentedAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce010MultipleFactoriesShareDocumentationTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce010MultipleFactoriesShareDocumentationTests.cs
new file mode 100644
index 0000000..8baf5a3
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce010MultipleFactoriesShareDocumentationTests.cs
@@ -0,0 +1,67 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce010MultipleFactoriesShareDocumentationTests {
+
+ [Fact]
+ public async Task Reports_each_factory_sharing_a_documentation_method() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError A() =>
+ DomainError.Create(ErrorCode.Create("A"), "diagnostic").WithPublicMessage("short");
+
+ [DocumentedBy(nameof(Doc))]
+ internal static DomainError B() =>
+ DomainError.Create(ErrorCode.Create("B"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new MultipleFactoriesShareDocumentationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(2);
+ Check.That(diagnostics.All(d => d.Id == "FCE010")).IsTrue();
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_each_factory_has_its_own_documentation() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(DocA))]
+ internal static DomainError A() =>
+ DomainError.Create(ErrorCode.Create("A"), "diagnostic").WithPublicMessage("short");
+
+ [DocumentedBy(nameof(DocB))]
+ internal static DomainError B() =>
+ DomainError.Create(ErrorCode.Create("B"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation DocA() =>
+ DescribeError.WithTitle("a").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+
+ private static ErrorDocumentation DocB() =>
+ DescribeError.WithTitle("b").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new MultipleFactoriesShareDocumentationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce011DuplicateDocumentedCodeTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce011DuplicateDocumentedCodeTests.cs
new file mode 100644
index 0000000..d9af1fc
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce011DuplicateDocumentedCodeTests.cs
@@ -0,0 +1,104 @@
+using System.Collections.Immutable;
+using System.Linq;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce011DuplicateDocumentedCodeTests {
+
+ [Fact]
+ public async Task Reports_when_two_documented_factories_share_a_code_field() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(DocA))]
+ internal static DomainError A() =>
+ DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short");
+
+ [DocumentedBy(nameof(DocB))]
+ internal static DomainError B() =>
+ DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation DocA() =>
+ DescribeError.WithTitle("a").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+
+ private static ErrorDocumentation DocB() =>
+ DescribeError.WithTitle("b").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+
+ private static class Code {
+ public static readonly ErrorCode Shared = ErrorCode.Create("SHARED");
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateDocumentedCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(2);
+ Check.That(diagnostics.All(d => d.Id == "FCE011")).IsTrue();
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_documented_factories_use_distinct_code_fields() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ [DocumentedBy(nameof(DocA))]
+ internal static DomainError A() =>
+ DomainError.Create(Code.A, "diagnostic").WithPublicMessage("short");
+
+ [DocumentedBy(nameof(DocB))]
+ internal static DomainError B() =>
+ DomainError.Create(Code.B, "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation DocA() =>
+ DescribeError.WithTitle("a").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+
+ private static ErrorDocumentation DocB() =>
+ DescribeError.WithTitle("b").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+
+ private static class Code {
+ public static readonly ErrorCode A = ErrorCode.Create("A");
+ public static readonly ErrorCode B = ErrorCode.Create("B");
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateDocumentedCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_the_sharing_factories_are_undocumented() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ internal static DomainError A() =>
+ DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short");
+
+ internal static DomainError B() =>
+ DomainError.Create(Code.Shared, "diagnostic").WithPublicMessage("short");
+
+ private static class Code {
+ public static readonly ErrorCode Shared = ErrorCode.Create("SHARED");
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateDocumentedCodeAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce012EmptyExamplesTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce012EmptyExamplesTests.cs
new file mode 100644
index 0000000..de3a6a3
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce012EmptyExamplesTests.cs
@@ -0,0 +1,51 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce012EmptyExamplesTests {
+
+ [Fact]
+ public async Task Reports_when_with_examples_has_no_factory() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyExamplesAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE012");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_an_example_is_provided() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples(() => Boom());
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyExamplesAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce013ExampleDoesNotCallDocumentedFactoryTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce013ExampleDoesNotCallDocumentedFactoryTests.cs
new file mode 100644
index 0000000..f3a076b
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce013ExampleDoesNotCallDocumentedFactoryTests.cs
@@ -0,0 +1,75 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce013ExampleDoesNotCallDocumentedFactoryTests {
+
+ [Fact]
+ public async Task Reports_when_example_does_not_call_a_factory_of_the_documenting_type() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Other {
+ public static DomainError Build() =>
+ DomainError.Create(ErrorCode.Create("OTHER"), "diagnostic").WithPublicMessage("short");
+ }
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic()
+ .WithExamples(() => Other.Build());
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ExampleDoesNotCallDocumentedFactoryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE013");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_example_calls_a_factory_of_the_documenting_type() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ internal static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("short");
+
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic()
+ .WithExamples(() => Boom());
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ExampleDoesNotCallDocumentedFactoryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_there_are_no_examples() {
+ const string source = """
+ using FirstClassErrors;
+
+ [ProvidesErrorsFor("Sample")]
+ public static class SampleError {
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("t").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ExampleDoesNotCallDocumentedFactoryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce014ShortMessageSameAsDetailedMessageTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce014ShortMessageSameAsDetailedMessageTests.cs
new file mode 100644
index 0000000..e754431
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce014ShortMessageSameAsDetailedMessageTests.cs
@@ -0,0 +1,62 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce014ShortMessageSameAsDetailedMessageTests {
+
+ [Fact]
+ public async Task Reports_when_short_and_detailed_are_identical() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("Same message", "Same message");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ShortMessageSameAsDetailedMessageAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE014");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_messages_differ() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("Short", "A longer detail");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ShortMessageSameAsDetailedMessageAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_only_short_message_is_given() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static DomainError Boom() =>
+ DomainError.Create(ErrorCode.Create("BOOM"), "diagnostic").WithPublicMessage("Short only");
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ShortMessageSameAsDetailedMessageAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce015DocumentationTitleTooGenericTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce015DocumentationTitleTooGenericTests.cs
new file mode 100644
index 0000000..b9c5b06
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce015DocumentationTitleTooGenericTests.cs
@@ -0,0 +1,46 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce015DocumentationTitleTooGenericTests {
+
+ [Fact]
+ public async Task Reports_a_generic_title() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("Invalid value").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentationTitleTooGenericAnalyzer(), source, "FCE015");
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE015");
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_specific_title() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ private static ErrorDocumentation Doc() =>
+ DescribeError.WithTitle("Temperature below absolute zero").WithDescription("d").WithoutRule().WithoutDiagnostic().WithExamples();
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DocumentationTitleTooGenericAnalyzer(), source, "FCE015");
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce016UnusedToExceptionResultTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce016UnusedToExceptionResultTests.cs
new file mode 100644
index 0000000..679a706
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce016UnusedToExceptionResultTests.cs
@@ -0,0 +1,65 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce016UnusedToExceptionResultTests {
+
+ [Fact]
+ public async Task Reports_when_result_is_discarded() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static void M(DomainError error) {
+ error.ToException();
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedToExceptionResultAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE016");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_thrown() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static void M(DomainError error) {
+ throw error.ToException();
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedToExceptionResultAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_result_is_captured() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static void M(DomainError error) {
+ var exception = error.ToException();
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedToExceptionResultAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj b/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj
new file mode 100644
index 0000000..db4a19a
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj
@@ -0,0 +1,36 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000..f50bb1f
--- /dev/null
+++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Shipped.md
@@ -0,0 +1,2 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000..5998171
--- /dev/null
+++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,23 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|--------------------------------------|----------|-------------------------------------
+FCE001 | FirstClassErrors.ErrorCodes | Error | DuplicateErrorCodeAnalyzer
+FCE002 | FirstClassErrors.ErrorCodes | Error | EmptyErrorCodeAnalyzer
+FCE003 | FirstClassErrors.ErrorCodes | Disabled | NonLiteralErrorCodeAnalyzer
+FCE004 | FirstClassErrors.ErrorCodes | Disabled | InvalidErrorCodeFormatAnalyzer
+FCE005 | FirstClassErrors.ErrorCodes | Disabled | TooGenericErrorCodeAnalyzer
+FCE006 | FirstClassErrors.DocumentationWiring | Error | DocumentedByTargetNotFoundAnalyzer
+FCE007 | FirstClassErrors.DocumentationWiring | Error | DocumentedByInvalidSignatureAnalyzer
+FCE008 | FirstClassErrors.DocumentationWiring | Error | DocumentedByWithoutProvidesErrorsForAnalyzer
+FCE009 | FirstClassErrors.DocumentationWiring | Warning | ErrorFactoryNotDocumentedAnalyzer
+FCE010 | FirstClassErrors.DocumentationWiring | Warning | MultipleFactoriesShareDocumentationAnalyzer
+FCE011 | FirstClassErrors.DocumentationContent| Error | DuplicateDocumentedCodeAnalyzer
+FCE012 | FirstClassErrors.DocumentationContent| Warning | EmptyExamplesAnalyzer
+FCE013 | FirstClassErrors.DocumentationContent| Warning | ExampleDoesNotCallDocumentedFactoryAnalyzer
+FCE014 | FirstClassErrors.DocumentationContent| Info | ShortMessageSameAsDetailedMessageAnalyzer
+FCE015 | FirstClassErrors.DocumentationContent| Disabled | DocumentationTitleTooGenericAnalyzer
+FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer
diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs
new file mode 100644
index 0000000..bc0540f
--- /dev/null
+++ b/FirstClassErrors.Analyzers/Descriptors.cs
@@ -0,0 +1,173 @@
+using Microsoft.CodeAnalysis;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// The for every FirstClassErrors rule. One field per FCExxx, added as the
+/// rule is implemented.
+///
+internal static class Descriptors {
+
+ public static readonly DiagnosticDescriptor DuplicateErrorCode = new(
+ id: DiagnosticIds.DuplicateErrorCode,
+ title: "Duplicate error code",
+ messageFormat: "Error code '{0}' is created more than once; each ErrorCode must be unique",
+ category: DiagnosticCategories.ErrorCodes,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "ErrorCode.Create registers each code in a process-wide set and throws when the same code is created twice. Detection is per-compilation and limited to literal codes.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateErrorCode),
+ customTags: new[] { WellKnownDiagnosticTags.CompilationEnd });
+
+ public static readonly DiagnosticDescriptor EmptyErrorCode = new(
+ id: DiagnosticIds.EmptyErrorCode,
+ title: "Error code must not be empty",
+ messageFormat: "Error code must not be null, empty or whitespace",
+ category: DiagnosticCategories.ErrorCodes,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "ErrorCode.Create requires a non-empty code; an empty or whitespace literal throws an ArgumentException at runtime.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.EmptyErrorCode));
+
+ public static readonly DiagnosticDescriptor NonLiteralErrorCode = new(
+ id: DiagnosticIds.NonLiteralErrorCode,
+ title: "Error code is not a compile-time literal",
+ messageFormat: "Error code is computed at runtime; duplicate-code analysis (FCE001) cannot verify it",
+ category: DiagnosticCategories.ErrorCodes,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: false,
+ description: "Only literal error codes can be checked statically. A code built at runtime is a blind spot for duplicate detection; this rule is opt-in for teams that want codes to stay literal.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.NonLiteralErrorCode));
+
+ public static readonly DiagnosticDescriptor InvalidErrorCodeFormat = new(
+ id: DiagnosticIds.InvalidErrorCodeFormat,
+ title: "Error code does not follow the UPPER_SNAKE_CASE convention",
+ messageFormat: "Error code '{0}' does not match the expected UPPER_SNAKE_CASE format",
+ category: DiagnosticCategories.ErrorCodes,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: false,
+ description: "A consistent code format keeps catalogs and logs scannable. This convention check is opt-in.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.InvalidErrorCodeFormat));
+
+ public static readonly DiagnosticDescriptor TooGenericErrorCode = new(
+ id: DiagnosticIds.TooGenericErrorCode,
+ title: "Error code is too generic",
+ messageFormat: "Error code '{0}' is too generic to identify a specific failure",
+ category: DiagnosticCategories.ErrorCodes,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: false,
+ description: "A code such as ERROR or INVALID carries no diagnostic value. Prefer a code that names the specific condition. Opt-in.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.TooGenericErrorCode));
+
+ public static readonly DiagnosticDescriptor DocumentedByTargetNotFound = new(
+ id: DiagnosticIds.DocumentedByTargetNotFound,
+ title: "Documentation method referenced by [DocumentedBy] was not found",
+ messageFormat: "No method named '{0}' exists on the type; [DocumentedBy] cannot be resolved and this error will not be documented",
+ category: DiagnosticCategories.DocumentationWiring,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "[DocumentedBy] references its documentation method by name; a name that resolves to nothing is silently skipped when documentation is extracted.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentedByTargetNotFound));
+
+ public static readonly DiagnosticDescriptor DocumentedByInvalidSignature = new(
+ id: DiagnosticIds.DocumentedByInvalidSignature,
+ title: "[DocumentedBy] target has an invalid signature",
+ messageFormat: "Method '{0}' must be static, parameterless and return ErrorDocumentation to be used by [DocumentedBy]",
+ category: DiagnosticCategories.DocumentationWiring,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "The documentation factory referenced by [DocumentedBy] is invoked as a static parameterless method returning ErrorDocumentation; any other shape is skipped at extraction time.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentedByInvalidSignature));
+
+ public static readonly DiagnosticDescriptor DocumentedByWithoutProvidesErrorsFor = new(
+ id: DiagnosticIds.DocumentedByWithoutProvidesErrorsFor,
+ title: "[DocumentedBy] used in a type without [ProvidesErrorsFor]",
+ messageFormat: "Type '{0}' declares [DocumentedBy] factories but is missing [ProvidesErrorsFor]; its error documentation will be silently ignored",
+ category: DiagnosticCategories.DocumentationWiring,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "Documentation extraction only scans types annotated with [ProvidesErrorsFor]; [DocumentedBy] methods on an unannotated type are never extracted.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentedByWithoutProvidesErrorsFor));
+
+ public static readonly DiagnosticDescriptor UnusedToExceptionResult = new(
+ id: DiagnosticIds.UnusedToExceptionResult,
+ title: "The result of ToException() is not used",
+ messageFormat: "The result of ToException() is discarded; did you mean to throw it?",
+ category: DiagnosticCategories.Usage,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "ToException() only builds an exception; discarding it as a standalone statement means nothing is thrown and the error is lost.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.UnusedToExceptionResult));
+
+ public static readonly DiagnosticDescriptor ErrorFactoryNotDocumented = new(
+ id: DiagnosticIds.ErrorFactoryNotDocumented,
+ title: "Error factory is not documented",
+ messageFormat: "Factory '{0}' returns an error but has no [DocumentedBy]; it will not appear in the generated documentation",
+ category: DiagnosticCategories.DocumentationWiring,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "A non-private static factory in a [ProvidesErrorsFor] type that returns an Error is expected to carry [DocumentedBy]; without it the error is left out of the generated catalog.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.ErrorFactoryNotDocumented));
+
+ public static readonly DiagnosticDescriptor MultipleFactoriesShareDocumentation = new(
+ id: DiagnosticIds.MultipleFactoriesShareDocumentation,
+ title: "Multiple factories share the same documentation",
+ messageFormat: "Documentation method '{0}' is referenced by more than one factory; each error should have its own documentation",
+ category: DiagnosticCategories.DocumentationWiring,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "One documentation method describes one error (its title, description and examples). Sharing it between factories means at least one error is mis-documented.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.MultipleFactoriesShareDocumentation));
+
+ public static readonly DiagnosticDescriptor EmptyExamples = new(
+ id: DiagnosticIds.EmptyExamples,
+ title: "Documentation declares no examples",
+ messageFormat: "WithExamples was called without any example factory; add at least one representative example",
+ category: DiagnosticCategories.DocumentationContent,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Examples expose the real messages an error produces; calling WithExamples with no factory yields documentation that shows none.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.EmptyExamples));
+
+ public static readonly DiagnosticDescriptor DuplicateDocumentedCode = new(
+ id: DiagnosticIds.DuplicateDocumentedCode,
+ title: "Duplicate documented error code",
+ messageFormat: "Error code '{0}' is produced by more than one documented factory; documentation extraction keeps only one of them",
+ category: DiagnosticCategories.DocumentationContent,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "Documentation extraction groups by error code and keeps a single entry per code. Two documented factories that share the same code field silently collapse to one in the catalog.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicateDocumentedCode),
+ customTags: new[] { WellKnownDiagnosticTags.CompilationEnd });
+
+ public static readonly DiagnosticDescriptor ExampleDoesNotCallDocumentedFactory = new(
+ id: DiagnosticIds.ExampleDoesNotCallDocumentedFactory,
+ title: "Documentation example does not construct the documented error",
+ messageFormat: "This example does not call any factory of '{0}'; an example should build the error it documents",
+ category: DiagnosticCategories.DocumentationContent,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Examples are meant to expose the real messages of the documented error, so each should invoke a factory of the type that declares the documentation.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.ExampleDoesNotCallDocumentedFactory));
+
+ public static readonly DiagnosticDescriptor ShortMessageSameAsDetailedMessage = new(
+ id: DiagnosticIds.ShortMessageSameAsDetailedMessage,
+ title: "Short message duplicates the detailed message",
+ messageFormat: "The short public message is identical to the detailed message; give the caller a shorter summary",
+ category: DiagnosticCategories.DocumentationContent,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: "The short message is a public summary and the detailed message an optional public detail; making them identical usually signals a copy-paste.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.ShortMessageSameAsDetailedMessage));
+
+ public static readonly DiagnosticDescriptor DocumentationTitleTooGeneric = new(
+ id: DiagnosticIds.DocumentationTitleTooGeneric,
+ title: "Documentation title is too generic",
+ messageFormat: "Documentation title '{0}' is too generic; state what the error is",
+ category: DiagnosticCategories.DocumentationContent,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: false,
+ description: "A title such as \"Error\" or \"Invalid value\" tells the reader nothing. A good title names the condition (e.g. \"Temperature below absolute zero\"). Opt-in.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.DocumentationTitleTooGeneric));
+
+}
diff --git a/FirstClassErrors.Analyzers/DiagnosticCategories.cs b/FirstClassErrors.Analyzers/DiagnosticCategories.cs
new file mode 100644
index 0000000..17ca0d7
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DiagnosticCategories.cs
@@ -0,0 +1,13 @@
+namespace FirstClassErrors.Analyzers;
+
+///
+/// Categories used to group FirstClassErrors diagnostics in the IDE and in .editorconfig.
+///
+internal static class DiagnosticCategories {
+
+ public const string ErrorCodes = "FirstClassErrors.ErrorCodes";
+ public const string DocumentationWiring = "FirstClassErrors.DocumentationWiring";
+ public const string DocumentationContent = "FirstClassErrors.DocumentationContent";
+ public const string Usage = "FirstClassErrors.Usage";
+
+}
diff --git a/FirstClassErrors.Analyzers/DiagnosticIds.cs b/FirstClassErrors.Analyzers/DiagnosticIds.cs
new file mode 100644
index 0000000..f8c4026
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DiagnosticIds.cs
@@ -0,0 +1,33 @@
+namespace FirstClassErrors.Analyzers;
+
+///
+/// Stable identifiers for every FirstClassErrors diagnostic. The number is only a stable handle; rules are grouped
+/// for the user through , not through contiguous numbering.
+///
+internal static class DiagnosticIds {
+
+ // Category: ErrorCodes
+ public const string DuplicateErrorCode = "FCE001";
+ public const string EmptyErrorCode = "FCE002";
+ public const string NonLiteralErrorCode = "FCE003";
+ public const string InvalidErrorCodeFormat = "FCE004";
+ public const string TooGenericErrorCode = "FCE005";
+
+ // Category: DocumentationWiring
+ public const string DocumentedByTargetNotFound = "FCE006";
+ public const string DocumentedByInvalidSignature = "FCE007";
+ public const string DocumentedByWithoutProvidesErrorsFor = "FCE008";
+ public const string ErrorFactoryNotDocumented = "FCE009";
+ public const string MultipleFactoriesShareDocumentation = "FCE010";
+
+ // Category: DocumentationContent
+ public const string DuplicateDocumentedCode = "FCE011";
+ public const string EmptyExamples = "FCE012";
+ public const string ExampleDoesNotCallDocumentedFactory = "FCE013";
+ public const string ShortMessageSameAsDetailedMessage = "FCE014";
+ public const string DocumentationTitleTooGeneric = "FCE015";
+
+ // Category: Usage
+ public const string UnusedToExceptionResult = "FCE016";
+
+}
diff --git a/FirstClassErrors.Analyzers/DocumentationTitleTooGenericAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentationTitleTooGenericAnalyzer.cs
new file mode 100644
index 0000000..5de6df6
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DocumentationTitleTooGenericAnalyzer.cs
@@ -0,0 +1,55 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE015 — reports a WithTitle("...") whose literal title is one of a small denylist of empty phrases
+/// (Error, Invalid value, Failure…) that describe nothing. Opt-in: disabled by default.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class DocumentationTitleTooGenericAnalyzer : DiagnosticAnalyzer {
+
+ private const string ErrorDescriptionStageMetadataName = "FirstClassErrors.IErrorDescriptionStage";
+ private const string WithTitleMethodName = "WithTitle";
+
+ private static readonly ImmutableHashSet GenericTitles = ImmutableHashSet.Create(
+ StringComparer.OrdinalIgnoreCase,
+ "Error", "Invalid", "Invalid value", "Failure", "Failed", "Unknown", "Unknown error", "Bad", "Problem", "Something went wrong");
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.DocumentationTitleTooGeneric);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ INamedTypeSymbol? descriptionStageType = context.Compilation.GetTypeByMetadataName(ErrorDescriptionStageMetadataName);
+ if (descriptionStageType is null) { return; }
+
+ context.RegisterOperationAction(operationContext => Analyze(operationContext, descriptionStageType), OperationKind.Invocation);
+ }
+
+ private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol descriptionStageType) {
+ IInvocationOperation invocation = (IInvocationOperation)context.Operation;
+ IMethodSymbol method = invocation.TargetMethod;
+
+ if (method.Name != WithTitleMethodName) { return; }
+ if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, descriptionStageType)) { return; }
+ if (invocation.Arguments.Length != 1) { return; }
+
+ if (invocation.Arguments[0].Value.ConstantValue.Value is not string title) { return; }
+ if (!GenericTitles.Contains(title.Trim())) { return; }
+
+ context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentationTitleTooGeneric, invocation.Arguments[0].Value.Syntax.GetLocation(), title));
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs
new file mode 100644
index 0000000..f559646
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DocumentedByInvalidSignatureAnalyzer.cs
@@ -0,0 +1,64 @@
+using System.Collections.Immutable;
+using System.Linq;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE007 — reports a [DocumentedBy("X")] whose target method exists but cannot be used as a documentation
+/// factory: it must be static, parameterless and return ErrorDocumentation. A missing target is FCE006's
+/// concern, not this one.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class DocumentedByInvalidSignatureAnalyzer : DiagnosticAnalyzer {
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.DocumentedByInvalidSignature);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ KnownSymbols symbols = KnownSymbols.From(context.Compilation);
+ if (symbols.DocumentedByAttribute is null) { return; }
+
+ context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.Method);
+ }
+
+ private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) {
+ IMethodSymbol method = (IMethodSymbol)context.Symbol;
+
+ if (!SymbolFacts.TryGetDocumentedBy(method, symbols.DocumentedByAttribute!, out AttributeData? attribute, out string? targetName)) { return; }
+ if (string.IsNullOrEmpty(targetName)) { return; }
+
+ ImmutableArray candidates = method.ContainingType
+ .GetMembers(targetName!)
+ .OfType()
+ .ToImmutableArray();
+
+ if (candidates.Length == 0) { return; } // not found → FCE006
+ if (candidates.Any(candidate => IsValidDocumentationMethod(candidate, symbols.ErrorDocumentation))) { return; }
+
+ Location location = attribute!.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation()
+ ?? method.Locations.FirstOrDefault()
+ ?? Location.None;
+
+ context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentedByInvalidSignature, location, targetName));
+ }
+
+ private static bool IsValidDocumentationMethod(IMethodSymbol candidate, INamedTypeSymbol? errorDocumentationType) {
+ if (!candidate.IsStatic) { return false; }
+ if (candidate.Parameters.Length != 0) { return false; }
+ if (errorDocumentationType is null) { return true; } // cannot verify the return type; avoid a false positive
+
+ return SymbolFacts.IsOrInheritsFrom(candidate.ReturnType, errorDocumentationType);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs
new file mode 100644
index 0000000..28db9b5
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DocumentedByTargetNotFoundAnalyzer.cs
@@ -0,0 +1,53 @@
+using System.Collections.Immutable;
+using System.Linq;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE006 — reports a [DocumentedBy("X")] whose target method name does not exist on the containing type.
+/// The documentation reference is resolved by name at extraction time, so a typo fails silently.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class DocumentedByTargetNotFoundAnalyzer : DiagnosticAnalyzer {
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.DocumentedByTargetNotFound);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ KnownSymbols symbols = KnownSymbols.From(context.Compilation);
+ if (symbols.DocumentedByAttribute is null) { return; }
+
+ context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.Method);
+ }
+
+ private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) {
+ IMethodSymbol method = (IMethodSymbol)context.Symbol;
+
+ if (!SymbolFacts.TryGetDocumentedBy(method, symbols.DocumentedByAttribute!, out AttributeData? attribute, out string? targetName)) { return; }
+ if (string.IsNullOrEmpty(targetName)) { return; }
+
+ bool exists = method.ContainingType
+ .GetMembers(targetName!)
+ .OfType()
+ .Any();
+ if (exists) { return; }
+
+ Location location = attribute!.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation()
+ ?? method.Locations.FirstOrDefault()
+ ?? Location.None;
+
+ context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentedByTargetNotFound, location, targetName));
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs b/FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs
new file mode 100644
index 0000000..5ccc18d
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DocumentedByWithoutProvidesErrorsForAnalyzer.cs
@@ -0,0 +1,51 @@
+using System.Collections.Immutable;
+using System.Linq;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE008 — reports a type that declares [DocumentedBy] factories but is missing [ProvidesErrorsFor].
+/// Documentation extraction only scans types carrying [ProvidesErrorsFor], so every documented error on such
+/// a type is silently ignored. Reported once per type.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class DocumentedByWithoutProvidesErrorsForAnalyzer : DiagnosticAnalyzer {
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.DocumentedByWithoutProvidesErrorsFor);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ KnownSymbols symbols = KnownSymbols.From(context.Compilation);
+ if (symbols.DocumentedByAttribute is null || symbols.ProvidesErrorsForAttribute is null) { return; }
+
+ context.RegisterSymbolAction(symbolContext => Analyze(symbolContext, symbols), SymbolKind.NamedType);
+ }
+
+ private static void Analyze(SymbolAnalysisContext context, KnownSymbols symbols) {
+ INamedTypeSymbol type = (INamedTypeSymbol)context.Symbol;
+
+ if (type.TypeKind != TypeKind.Class) { return; }
+ if (SymbolFacts.HasAttribute(type, symbols.ProvidesErrorsForAttribute!)) { return; }
+
+ bool hasDocumentedFactory = type.GetMembers()
+ .OfType()
+ .Any(method => SymbolFacts.HasAttribute(method, symbols.DocumentedByAttribute!));
+ if (!hasDocumentedFactory) { return; }
+
+ Location location = type.Locations.FirstOrDefault() ?? Location.None;
+
+ context.ReportDiagnostic(Diagnostic.Create(Descriptors.DocumentedByWithoutProvidesErrorsFor, location, type.Name));
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs
new file mode 100644
index 0000000..5bc4139
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DuplicateDocumentedCodeAnalyzer.cs
@@ -0,0 +1,95 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Linq;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE011 — reports when more than one documented factory produces the same error code by referencing the same
+/// ErrorCode field. Documentation extraction groups by code and keeps a single entry, so the others collapse
+/// silently. This complements FCE001, which only sees duplicate ErrorCode.Create literals.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class DuplicateDocumentedCodeAnalyzer : DiagnosticAnalyzer {
+
+ private const string CreateMethodName = "Create";
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.DuplicateDocumentedCode);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ KnownSymbols symbols = KnownSymbols.From(context.Compilation);
+ if (symbols.Error is null || symbols.DocumentedByAttribute is null) { return; }
+
+ ConcurrentDictionary> usagesByCodeField = new(SymbolEqualityComparer.Default);
+
+ context.RegisterOperationBlockAction(blockContext => Collect(blockContext, symbols, usagesByCodeField));
+ context.RegisterCompilationEndAction(endContext => Report(endContext, usagesByCodeField));
+ }
+
+ private static void Collect(
+ OperationBlockAnalysisContext context,
+ KnownSymbols symbols,
+ ConcurrentDictionary> usagesByCodeField) {
+
+ if (context.OwningSymbol is not IMethodSymbol method) { return; }
+ if (!SymbolFacts.HasAttribute(method, symbols.DocumentedByAttribute!)) { return; }
+
+ foreach (IOperation block in context.OperationBlocks) {
+ foreach (IOperation operation in OperationFacts.EnumerateOperations(block)) {
+ if (operation is not IInvocationOperation invocation) { continue; }
+ if (invocation.TargetMethod.Name != CreateMethodName) { continue; }
+ if (!SymbolFacts.IsOrInheritsFrom(invocation.TargetMethod.ContainingType, symbols.Error!)) { continue; }
+ if (invocation.Arguments.Length == 0) { continue; }
+
+ if (TryGetCodeField(invocation.Arguments[0].Value, out ISymbol? codeField)) {
+ usagesByCodeField.GetOrAdd(codeField!, _ => new ConcurrentBag())
+ .Add(method.Locations.FirstOrDefault() ?? Location.None);
+ }
+
+ return; // the outermost error-factory Create identifies the produced code
+ }
+ }
+ }
+
+ private static bool TryGetCodeField(IOperation codeArgument, out ISymbol? codeField) {
+ IOperation value = codeArgument is IConversionOperation conversion ? conversion.Operand : codeArgument;
+
+ if (value is IFieldReferenceOperation fieldReference) {
+ codeField = fieldReference.Field;
+
+ return true;
+ }
+
+ codeField = null;
+
+ return false;
+ }
+
+ private static void Report(
+ CompilationAnalysisContext context,
+ ConcurrentDictionary> usagesByCodeField) {
+
+ foreach (KeyValuePair> entry in usagesByCodeField) {
+ Location[] locations = entry.Value.ToArray();
+ if (locations.Length < 2) { continue; }
+
+ foreach (Location location in locations) {
+ context.ReportDiagnostic(Diagnostic.Create(Descriptors.DuplicateDocumentedCode, location, entry.Key.Name));
+ }
+ }
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs b/FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs
new file mode 100644
index 0000000..c344b00
--- /dev/null
+++ b/FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs
@@ -0,0 +1,81 @@
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Linq;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE001 — reports the same error code created by more than one ErrorCode.Create("X") literal in the
+/// compilation. ErrorCode.Create registers each code in a process-wide set and throws when a code is created
+/// twice. Detection is per-compilation (cross-assembly duplicates still surface only at runtime) and limited to
+/// literal codes — a non-literal code is FCE003's concern.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class DuplicateErrorCodeAnalyzer : DiagnosticAnalyzer {
+
+ private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode";
+ private const string CreateMethodName = "Create";
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.DuplicateErrorCode);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName);
+ if (errorCodeType is null) { return; }
+
+ // Per-compilation state; ErrorCode registers with ordinal comparison, so duplicates are case-sensitive.
+ ConcurrentDictionary> occurrences = new(StringComparer.Ordinal);
+
+ context.RegisterOperationAction(operationContext => Collect(operationContext, errorCodeType, occurrences), OperationKind.Invocation);
+ context.RegisterCompilationEndAction(endContext => Report(endContext, occurrences));
+ }
+
+ private static void Collect(
+ OperationAnalysisContext context,
+ INamedTypeSymbol errorCodeType,
+ ConcurrentDictionary> occurrences) {
+
+ IInvocationOperation invocation = (IInvocationOperation)context.Operation;
+ IMethodSymbol method = invocation.TargetMethod;
+
+ if (!method.IsStatic || method.Name != CreateMethodName) { return; }
+ if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; }
+ if (invocation.Arguments.Length != 1) { return; }
+
+ IOperation argument = invocation.Arguments[0].Value;
+ Optional