Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9cd61d9
chore(analyzers): scaffold analyzer + test projects, solution wiring …
claude Jul 4, 2026
66c7932
feat(analyzers): add FCE002 EmptyErrorCode
claude Jul 4, 2026
d42ea98
feat(analyzers): add FCE006 DocumentedByTargetNotFound
claude Jul 4, 2026
0704efb
feat(analyzers): add FCE007 DocumentedByInvalidSignature
claude Jul 4, 2026
10fb332
feat(analyzers): add FCE008 DocumentedByWithoutProvidesErrorsFor
claude Jul 4, 2026
db7c3cb
chore(analyzers): scaffold analyzer + test projects, solution wiring …
claude Jul 4, 2026
8229f61
feat(analyzers): add FCE002 EmptyErrorCode
claude Jul 4, 2026
25f33ab
feat(analyzers): add FCE006 DocumentedByTargetNotFound
claude Jul 4, 2026
42551f4
feat(analyzers): add FCE007 DocumentedByInvalidSignature
claude Jul 4, 2026
1110bd9
feat(analyzers): add FCE008 DocumentedByWithoutProvidesErrorsFor
claude Jul 4, 2026
ebe26c5
Merge remote-tracking branch 'origin/analyzers' into analyzers
Reefact Jul 4, 2026
364cae4
feat(analyzers): add FCE001 DuplicateErrorCode
claude Jul 4, 2026
b1736af
Merge remote-tracking branch 'origin/analyzers' into analyzers
Reefact Jul 4, 2026
e82f3b8
feat(analyzers): add FCE016 UnusedToExceptionResult
claude Jul 4, 2026
e8ea977
feat(analyzers): add FCE009 ErrorFactoryNotDocumented
claude Jul 4, 2026
ee4e6fa
feat(analyzers): add FCE010 MultipleFactoriesShareDocumentation
claude Jul 4, 2026
fa43e38
feat(analyzers): add FCE012 EmptyExamples
claude Jul 4, 2026
fbe852f
build(analyzers): bundle the analyzer into the FirstClassErrors package
claude Jul 4, 2026
e96884c
build(analyzers): dogfood the analyzers on FirstClassErrors.Usage and…
claude Jul 4, 2026
162e449
Merge remote-tracking branch 'origin/analyzers' into analyzers
Reefact Jul 4, 2026
8aca050
fix(analyzers): tag FCE001 as a compilation-end diagnostic (RS1037)
claude Jul 4, 2026
38308af
Merge remote-tracking branch 'origin/analyzers' into analyzers
Reefact Jul 4, 2026
fe13baf
feat(analyzers): add FCE011 DuplicateDocumentedCode
claude Jul 4, 2026
6b9bac3
feat(analyzers): add FCE013 ExampleDoesNotCallDocumentedFactory
claude Jul 4, 2026
cd5b4aa
Merge remote-tracking branch 'origin/analyzers' into analyzers
Reefact Jul 4, 2026
ef8557a
feat(analyzers): add FCE003 NonLiteralErrorCode
claude Jul 4, 2026
004ead8
feat(analyzers): add FCE004 InvalidErrorCodeFormat
claude Jul 4, 2026
6d80c43
feat(analyzers): add FCE005 TooGenericErrorCode
claude Jul 4, 2026
33f2b6c
feat(analyzers): add FCE014 ShortMessageSameAsDetailedMessage
claude Jul 4, 2026
60c0274
feat(analyzers): add FCE015 DocumentationTitleTooGeneric
claude Jul 4, 2026
c3f0341
fix(analyzers): close the Descriptors class (brace dropped in FCE015)
claude Jul 4, 2026
34737f8
fix(analyzers): mark opt-in rules as Disabled in release tracking (RS…
claude Jul 4, 2026
0c7d8db
build(analyzers): surface analyzer-dev (RS) rules in CI via .editorco…
claude Jul 4, 2026
26e70cd
build(analyzers): drop the RS .editorconfig
claude Jul 4, 2026
48a5379
docs(analyzers): add per-rule reference pages and index
claude Jul 4, 2026
2b0a476
docs(analyzers): sync the French README with the analyzer section and…
claude Jul 4, 2026
e992809
docs(analyzers): make the rule pages bilingual (EN/FR)
claude Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/analyzers.yml
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
internal static class AnalyzerTestHarness {

private static readonly ImmutableArray<MetadataReference> BaseReferences = BuildBaseReferences();

public static async Task<ImmutableArray<Diagnostic>> 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<string, ReportDiagnostic>.Builder specific = ImmutableDictionary.CreateBuilder<string, ReportDiagnostic>();
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<MetadataReference> BuildBaseReferences() {
List<MetadataReference> 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();
}

}
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicateErrorCodeAnalyzer(), source);

Check.That(diagnostics.Length).IsEqualTo(0);
}

}
76 changes: 76 additions & 0 deletions FirstClassErrors.Analyzers.UnitTests/Fce002EmptyErrorCodeTests.cs
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyErrorCodeAnalyzer(), source);

Check.That(diagnostics.Length).IsEqualTo(0);
}

}
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> 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<Diagnostic> 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<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new NonLiteralErrorCodeAnalyzer(), source, "FCE003");

Check.That(diagnostics.Length).IsEqualTo(0);
}

}
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> 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<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InvalidErrorCodeFormatAnalyzer(), source, "FCE004");

Check.That(diagnostics.Length).IsEqualTo(0);
}

}
Loading
Loading