diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 000000000..ec69fb1f6 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,37 @@ +name: Code style + +on: + workflow_call: + workflow_dispatch: + +jobs: + verify: + name: Verify code style + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + env: + DOTNET_CLI_UI_LANGUAGE: en-US + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + - name: Restore solution + run: dotnet restore Eventuous.slnx + - name: Verify code style + shell: bash + run: | + set -euo pipefail + log_file="$RUNNER_TEMP/dotnet-format.log" + dotnet format style Eventuous.slnx --verify-no-changes --no-restore --severity warn --verbosity diagnostic 2>&1 | tee "$log_file" + if grep -Fq 'Required references did not load for ' "$log_file"; then + echo "::error::Code style verification was incomplete because project references could not be loaded." + exit 1 + fi diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 12822a0de..ac61ed572 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -9,6 +9,10 @@ on: - '*.md' jobs: + code-style: + name: Code style + uses: ./.github/workflows/code-style.yml + event_file: # Consumed by the Test Results workflow (test-results.yml) to publish results on the commit. name: "Event File" @@ -28,7 +32,7 @@ jobs: nuget: runs-on: ubuntu-latest - needs: tests + needs: [ tests, code-style ] permissions: id-token: write contents: read diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ea3cfac7c..4e6fa3de6 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -11,6 +11,10 @@ concurrency: cancel-in-progress: true jobs: + code-style: + name: Code style + uses: ./.github/workflows/code-style.yml + event_file: # Consumed by the Test Results workflow (test-results.yml) to publish results with the # correct PR context. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d952ad46..430d11011 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,4 +9,22 @@ Here are simple rules that we expect to be followed by contributors: - Use the code formats of the repository, we have them in the `.editorconfig` file. Reformat the code accordingly if you copy it from somewhere. - Only contribute with your own work, or if you have a permission from the others to contribute their code. +## Code style checks + +The reusable [code-style workflow](.github/workflows/code-style.yml) runs for pull requests and pushes to `dev`. + +Run the same style verification locally from the repository root: + +```sh +dotnet format style Eventuous.slnx --verify-no-changes --severity warn +``` + +This checks Roslyn code-style rules at warning or error severity, including naming conventions and unnecessary imports. Apply available fixes with: + +```sh +dotnet format style Eventuous.slnx --severity warn +``` + +Some violations, such as naming-rule violations, require manual fixes. Rerun verification after making changes. CI also fails if the formatter reports that required project references could not be loaded. + Thanks for contributing! diff --git a/src/Core/src/Eventuous.Diagnostics/EventuousDiagnostics.cs b/src/Core/src/Eventuous.Diagnostics/EventuousDiagnostics.cs index a5c6a58b0..5d65b56b9 100644 --- a/src/Core/src/Eventuous.Diagnostics/EventuousDiagnostics.cs +++ b/src/Core/src/Eventuous.Diagnostics/EventuousDiagnostics.cs @@ -9,13 +9,13 @@ public static class EventuousDiagnostics { static EventuousDiagnostics() { Enabled = Environment.GetEnvironmentVariable("EVENTUOUS_DISABLE_DIAGS") != "1"; ActivitySource = new(InstrumentationName, InstrumentationVersion.Value); - listener = DummyActivityListener.Create(); - ActivitySource.AddActivityListener(listener); + Listener = DummyActivityListener.Create(); + ActivitySource.AddActivityListener(Listener); } public const string InstrumentationName = DiagnosticName.BaseName; - static readonly ActivityListener listener; + static readonly ActivityListener Listener; public static KeyValuePair[] Tags { get; private set; } = []; @@ -46,7 +46,7 @@ public static void AddDefaultTag(string key, object? value) { public static ActivitySource ActivitySource { get; } - public static void RemoveDummyListener() => listener.Dispose(); + public static void RemoveDummyListener() => Listener.Dispose(); public static Meter GetMeter(string name) => new(name, InstrumentationVersion.Value); diff --git a/src/Core/src/Eventuous.Persistence/ExpectedStreamVersion.cs b/src/Core/src/Eventuous.Persistence/ExpectedStreamVersion.cs index 1c33e2c72..8a824e7a1 100644 --- a/src/Core/src/Eventuous.Persistence/ExpectedStreamVersion.cs +++ b/src/Core/src/Eventuous.Persistence/ExpectedStreamVersion.cs @@ -11,10 +11,10 @@ public readonly record struct ExpectedStreamVersion(long Value) { } public record struct StreamReadPosition { - public StreamReadPosition(long Value) { - if (Value < 0) throw new ArgumentOutOfRangeException(nameof(Value), "StreamReadPosition cannot be negative."); + public StreamReadPosition(long value) { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), "StreamReadPosition cannot be negative."); - this.Value = Value; + Value = value; } public static readonly StreamReadPosition Start = new(0L); diff --git a/src/Core/src/Eventuous.Serialization/EventSerializer.cs b/src/Core/src/Eventuous.Serialization/EventSerializer.cs index 7d33f9c86..5f98d065e 100644 --- a/src/Core/src/Eventuous.Serialization/EventSerializer.cs +++ b/src/Core/src/Eventuous.Serialization/EventSerializer.cs @@ -10,12 +10,12 @@ namespace Eventuous; /// [PublicAPI] public static class EventSerializer { - static IEventSerializer? _default; + static IEventSerializer? defaultSerializer; /// /// Gets the default event serializer. Throws if not configured. /// - public static IEventSerializer Default => _default + public static IEventSerializer Default => defaultSerializer ?? throw new InvalidOperationException( "No default event serializer configured. " + "Call EventSerializer.SetDefault() at startup or register IEventSerializer in DI. " @@ -25,7 +25,7 @@ public static class EventSerializer { /// Sets the default event serializer instance. /// public static void SetDefault(IEventSerializer serializer) - => _default = serializer ?? throw new ArgumentNullException(nameof(serializer)); + => defaultSerializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); /// /// Sets the default event serializer only if one has not already been configured. @@ -34,6 +34,6 @@ public static void SetDefault(IEventSerializer serializer) public static bool TrySetDefault(IEventSerializer serializer) { ArgumentNullException.ThrowIfNull(serializer); - return Interlocked.CompareExchange(ref _default, serializer, null) == null; + return Interlocked.CompareExchange(ref defaultSerializer, serializer, null) == null; } } diff --git a/src/Core/src/Eventuous.Shared/TypeMap/TypeMap.cs b/src/Core/src/Eventuous.Shared/TypeMap/TypeMap.cs index 2b051964f..e973af2de 100644 --- a/src/Core/src/Eventuous.Shared/TypeMap/TypeMap.cs +++ b/src/Core/src/Eventuous.Shared/TypeMap/TypeMap.cs @@ -46,23 +46,22 @@ struct MapEntry { public TV? Value { get; set; } } - // ReSharper disable once InconsistentNaming // ReSharper disable once StaticMemberInGenericType - static volatile int _index = -1; + static volatile int index = -1; const int DefaultCapacity = 10; // ReSharper disable once UnusedTypeParameter static class TypeSlot { // ReSharper disable once StaticMemberInGenericType - internal static readonly int Index = Interlocked.Increment(ref _index); + internal static readonly int Index = Interlocked.Increment(ref index); } static int GetIndex() => TypeSlot.Index; static int RecommendedCapacity { get { - var capacity = _index + 1; + var capacity = index + 1; if (capacity < DefaultCapacity) { capacity = DefaultCapacity; diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs index 3c547b6d8..17fb5221e 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs @@ -112,7 +112,7 @@ public async Task Should_stay_silent_when_event_type_attribute_cannot_be_resolve var compilation = CreateCompilation(source, includeSharedAssembly: false); var analyzer = new EventUsageAnalyzer(); - var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer); + var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer, allowCompilationErrors: true); await Assert.That(diagnostics.Length).IsEqualTo(0); } @@ -128,11 +128,18 @@ public async Task Should_resolve_all_well_known_type_names() { await Assert.That(unresolved).IsEmpty(); } - static async Task GetAnalyzerDiagnosticsAsync(Compilation compilation, EventUsageAnalyzer analyzer) { + static async Task GetAnalyzerDiagnosticsAsync(Compilation compilation, EventUsageAnalyzer analyzer, bool allowCompilationErrors = false) { + if (!allowCompilationErrors) { + var errors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToArray(); + await Assert.That(errors).IsEmpty(); + } + var withAnalyzers = compilation.WithAnalyzers([analyzer]); var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync().ConfigureAwait(false); - // Filter out anything not from our analyzer id just in case - return [.. diagnostics.Where(d => d.Id == EventUsageAnalyzer.DiagnosticId)]; + + // Fail on unexpected diagnostics, including analyzer failures such as AD0001. + await Assert.That(diagnostics.Where(d => d.Id != EventUsageAnalyzer.DiagnosticId)).IsEmpty(); + return [.. diagnostics]; } static string LoadAnalyzedSource([CallerFilePath] string? caller = null) { @@ -148,7 +155,8 @@ static CSharpCompilation CreateCompilation(string source, bool includeSharedAsse MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location), MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location), MetadataReference.CreateFromFile(typeof(State<>).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Aggregate<>).Assembly.Location) + MetadataReference.CreateFromFile(typeof(Aggregate<>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(JetBrains.Annotations.UsedImplicitlyAttribute).Assembly.Location) }; if (includeSharedAssembly) { @@ -162,7 +170,7 @@ static CSharpCompilation CreateCompilation(string source, bool includeSharedAsse var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; refs.AddRange(Directory.GetFiles(runtimeDir, "System.*.dll").Select(dll => MetadataReference.CreateFromFile(dll)).Cast()); - TryAddRef(refs, "netstandard"); + refs.Add(MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "netstandard.dll"))); var compilation = CSharpCompilation.Create( assemblyName: "Analyzer_Ev001_Tests_Assembly", @@ -176,14 +184,4 @@ static CSharpCompilation CreateCompilation(string source, bool includeSharedAsse return compilation; } - - static void TryAddRef(List refs, string asmName) { - try { - var asm = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == asmName); - if (asm != null) refs.Add(MetadataReference.CreateFromFile(asm.Location)); - } - catch { - // ignore - } - } } diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj index 4df435ab6..d0d703750 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj @@ -4,6 +4,11 @@ Exe true + + + + + diff --git a/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs b/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs index 0c8f0e550..c99a56dd3 100644 --- a/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs +++ b/src/Mongo/test/Eventuous.Tests.Projections.MongoDB/Fixtures/IntegrationFixture.cs @@ -42,7 +42,9 @@ public async Task InitializeAsync() { var settings = KurrentDBClientSettings.Create(_esdbContainer.GetConnectionString()); Client = new(settings); EventStore = new KurrentDBEventStore(Client); - _mongoContainer = new MongoDbBuilder().WithImage("mongo:8").Build(); + // MongoDB 8 cannot start on affected Linux 6.19+ kernels used by CI runners. + // MongoDB 7.0 is unaffected: https://jira.mongodb.org/browse/SERVER-125742 + _mongoContainer = new MongoDbBuilder().WithImage("mongo:7.0").Build(); await _mongoContainer.StartAsync(); var mongoSettings = MongoClientSettings.FromConnectionString(_mongoContainer.GetConnectionString()); Mongo = new MongoClient(mongoSettings).GetDatabase("bookings"); diff --git a/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapAgeReleaseWhileRunningTest.cs b/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapAgeReleaseWhileRunningTest.cs index 31e65c536..0b1d9dd87 100644 --- a/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapAgeReleaseWhileRunningTest.cs +++ b/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapAgeReleaseWhileRunningTest.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. using Eventuous.Postgresql.Subscriptions; -using Eventuous.Tests.Persistence.Base.Fixtures; using Eventuous.Tests.Subscriptions.Base; namespace Eventuous.Tests.Postgres.Subscriptions; diff --git a/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapRemediationBeforeSkipTest.cs b/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapRemediationBeforeSkipTest.cs index 45b69ca20..30eada74e 100644 --- a/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapRemediationBeforeSkipTest.cs +++ b/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapRemediationBeforeSkipTest.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. using Eventuous.Postgresql.Subscriptions; -using Eventuous.Tests.Persistence.Base.Fixtures; using Eventuous.Tests.Subscriptions.Base; namespace Eventuous.Tests.Postgres.Subscriptions; diff --git a/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapSkipTimeoutTest.cs b/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapSkipTimeoutTest.cs index 2cc308a88..eda09bfd3 100644 --- a/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapSkipTimeoutTest.cs +++ b/src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/GapSkipTimeoutTest.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. using Eventuous.Postgresql.Subscriptions; -using Eventuous.Tests.Persistence.Base.Fixtures; using Eventuous.Tests.Subscriptions.Base; namespace Eventuous.Tests.Postgres.Subscriptions; diff --git a/src/Sqlite/test/Eventuous.Tests.Sqlite/Metrics/MetricsFixture.cs b/src/Sqlite/test/Eventuous.Tests.Sqlite/Metrics/MetricsFixture.cs index e063132de..36f9a21eb 100644 --- a/src/Sqlite/test/Eventuous.Tests.Sqlite/Metrics/MetricsFixture.cs +++ b/src/Sqlite/test/Eventuous.Tests.Sqlite/Metrics/MetricsFixture.cs @@ -6,7 +6,6 @@ using Eventuous.Sqlite.Subscriptions; using Eventuous.Subscriptions.Checkpoints; using Eventuous.Subscriptions.Registrations; -using Eventuous.TestHelpers.TUnit.Logging; using Eventuous.Tests.OpenTelemetry.Fakes; using Eventuous.Tests.OpenTelemetry.Fixtures; using Eventuous.Tests.Sqlite.Fixtures;