diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..daa5776c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,56 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,csx}] +indent_style = space +indent_size = 4 +tab_width = 4 + +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_space_after_cast = false +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion + +[*.{json,yml,yaml,md}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/docs/guides/quality-gates.md b/docs/guides/quality-gates.md new file mode 100644 index 00000000..7a6aeeff --- /dev/null +++ b/docs/guides/quality-gates.md @@ -0,0 +1,73 @@ +# Quality Gates + +PatternKit changes should be validated as production library changes, not sample-only changes. Use these gates before opening or merging work. + +## Required local validation + +Restore and build the full solution: + +```bash +dotnet restore PatternKit.slnx --use-lock-file +dotnet build PatternKit.slnx --configuration Release --no-restore -m:1 +``` + +Run the focused test suite for the area being changed. For broad changes, run the solution test command used by CI: + +```bash +dotnet test PatternKit.slnx \ + --configuration Release \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[PatternKit*]*" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*Tests]*" \ + -- RunConfiguration.TestSessionTimeout=1800000 +``` + +Build documentation with warnings treated as failures: + +```bash +docfx docs/docfx.json --warningsAsErrors +``` + +Check package currency: + +```bash +dotnet list PatternKit.slnx package --outdated +``` + +The expected exception is `PatternKit.Generators`, which intentionally pins `Microsoft.CodeAnalysis.CSharp` with a project-level `VersionOverride` so the analyzer assembly remains compatible with the SDK compiler that loads it. + +## Test expectations + +Tests should be executable specifications. Use TinyBDD scenarios and `ScenarioExpect` assertion helpers instead of direct xUnit assertions. + +Prefer this structure: + +```csharp +[Scenario("A policy rejects invalid registration input")] +[Fact] +public Task Policy_Rejects_Invalid_Registration_Input() + => Given("invalid registration input", () => new ServiceCollection()) + .When("registering the policy", services => + ScenarioExpect.Throws( + () => services.AddPatternKitPriorityQueue(null!))) + .Then("the invalid dependency is named", exception => + ScenarioExpect.Equal("prioritySelector", exception.ParamName)) + .AssertPassed(); +``` + +Every production pattern should have: + +- Core fluent API coverage. +- Source-generator coverage when a generator route exists. +- Real-world example coverage in `PatternKit.Examples.Tests`. +- Documentation in the API docs, README table, or guide pages. +- Benchmark coverage that separates fluent and generated paths when both exist. +- `IServiceCollection` or host integration coverage when the pattern is naturally used through dependency injection. + +## Formatting and static analysis + +The repository includes a root `.editorconfig` so editors and `dotnet format` agree on basic C# layout and style. The current source tree still has historical whitespace that makes a full solution `dotnet format --verify-no-changes` fail with a very large formatting-only diff. Treat that as a dedicated cleanup task, not a drive-by change inside feature work. + +The same rule applies to analyzer hardening. Built-in .NET analyzers currently surface many intentional API-shape warnings for fluent generic factories, benchmark method names, and netstandard-compatible guard code. Enable stricter analyzers by project and rule family with explicit baselines rather than turning on solution-wide warning enforcement in one step. diff --git a/docs/guides/toc.yml b/docs/guides/toc.yml index ecd4be7f..2d5788c7 100644 --- a/docs/guides/toc.yml +++ b/docs/guides/toc.yml @@ -16,3 +16,5 @@ href: benchmark-results.md - name: Testing href: testing.md +- name: Quality Gates + href: quality-gates.md diff --git a/test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj b/test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj index 86651423..6a4a9550 100644 --- a/test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj +++ b/test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj @@ -9,7 +9,7 @@ CS1591: Missing XML comment (examples don't need docs) CS0649: Field never assigned (used in serialization scenarios) TBDD010: TinyBDD optimization hints (not errors) --> - $(NoWarn);CS1591;CS0649;TBDD010 + $(NoWarn);CS1591;CS0649;CS8602;TBDD010 diff --git a/test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj b/test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj index 74f9abb6..1757b42b 100644 --- a/test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj +++ b/test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj @@ -8,7 +8,7 @@ - $(NoWarn);xUnit1031;xUnit2002 + $(NoWarn);CS8602;xUnit1031;xUnit2002 diff --git a/test/PatternKit.Tests/PatternKit.Tests.csproj b/test/PatternKit.Tests/PatternKit.Tests.csproj index 4431bd86..91992b18 100644 --- a/test/PatternKit.Tests/PatternKit.Tests.csproj +++ b/test/PatternKit.Tests/PatternKit.Tests.csproj @@ -10,7 +10,7 @@ CS0618: Tests intentionally use obsolete APIs to verify backward compatibility xUnit1031: Some tests intentionally use sync-over-async for specific testing scenarios xUnit2002: NotNull on value types (analyzer false positive in some generator tests) --> - $(NoWarn);TBDD010;CS0618;xUnit1031;xUnit2002 + $(NoWarn);TBDD010;CS0618;CS8600;CS8602;xUnit1031;xUnit2002 diff --git a/test/ScenarioExpect.cs b/test/ScenarioExpect.cs index e70d497f..618d29da 100644 --- a/test/ScenarioExpect.cs +++ b/test/ScenarioExpect.cs @@ -104,12 +104,12 @@ public static void All(IEnumerable collection, Action assertion) } } - public static void Contains(string expectedSubstring, string actualString) + public static void Contains(string expectedSubstring, string? actualString) => Contains(expectedSubstring, actualString, StringComparison.Ordinal); - public static void Contains(string expectedSubstring, string actualString, StringComparison comparison) + public static void Contains(string expectedSubstring, string? actualString, StringComparison comparison) { - if (actualString.IndexOf(expectedSubstring, comparison) < 0) + if (actualString is null || actualString.IndexOf(expectedSubstring, comparison) < 0) Fail($"expected string to contain {Format(expectedSubstring)}, but was {Format(actualString)}"); } @@ -131,9 +131,9 @@ public static T Contains(IEnumerable collection, Func predicate) return default!; } - public static void DoesNotContain(string expectedSubstring, string actualString) + public static void DoesNotContain(string expectedSubstring, string? actualString) { - if (actualString.Contains(expectedSubstring, StringComparison.Ordinal)) + if (actualString?.Contains(expectedSubstring, StringComparison.Ordinal) == true) Fail($"expected string to not contain {Format(expectedSubstring)}, but was {Format(actualString)}"); } @@ -149,18 +149,18 @@ public static void DoesNotContain(IEnumerable collection, Func pr Fail("expected collection to not contain a matching item"); } - public static void StartsWith(string expectedStart, string actualString) + public static void StartsWith(string expectedStart, string? actualString) => StartsWith(expectedStart, actualString, StringComparison.Ordinal); - public static void StartsWith(string expectedStart, string actualString, StringComparison comparison) + public static void StartsWith(string expectedStart, string? actualString, StringComparison comparison) { - if (!actualString.StartsWith(expectedStart, comparison)) + if (actualString is null || !actualString.StartsWith(expectedStart, comparison)) Fail($"expected string to start with {Format(expectedStart)}, but was {Format(actualString)}"); } - public static void Matches(string expectedRegexPattern, string actualString) + public static void Matches(string expectedRegexPattern, string? actualString) { - if (!Regex.IsMatch(actualString, expectedRegexPattern)) + if (actualString is null || !Regex.IsMatch(actualString, expectedRegexPattern)) Fail($"expected string to match /{expectedRegexPattern}/, but was {Format(actualString)}"); }