KirillMaster.QualityGates.Build
0.3.1
dotnet add package KirillMaster.QualityGates.Build --version 0.3.1
NuGet\Install-Package KirillMaster.QualityGates.Build -Version 0.3.1
<PackageReference Include="KirillMaster.QualityGates.Build" Version="0.3.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="KirillMaster.QualityGates.Build" Version="0.3.1" />
<PackageReference Include="KirillMaster.QualityGates.Build"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add KirillMaster.QualityGates.Build --version 0.3.1
#r "nuget: KirillMaster.QualityGates.Build, 0.3.1"
#:package KirillMaster.QualityGates.Build@0.3.1
#addin nuget:?package=KirillMaster.QualityGates.Build&version=0.3.1
#tool nuget:?package=KirillMaster.QualityGates.Build&version=0.3.1
quality-gates
Reusable quality gates for modern .NET (net8 / net10). MIT licensed.
| Package | Purpose |
|---|---|
KirillMaster.QualityGates.Build |
Build-only package: warnings-as-errors, AnalysisLevel 10.0-all (pinned), nullable, curated third-party analyzers (Sonar, Meziantou, Roslynator, VS.Threading, IDisposableAnalyzers, BannedApiAnalyzers), own Roslyn analyzers QG0xxx, quality.globalconfig, BannedSymbols.txt, .editorconfig template, grandfathered suppressions. |
KirillMaster.QualityGates.Architecture |
ArchUnitNET-based test base for hexagonal layering, config-driven layers, namespace-cycle and type-cycle (baseline) checks, repository meta-tests (locked settings, central package versions, suppression ratchet, every project covered). |
KirillMaster.QualityGates.Results |
Minimal Result, Result<T>, Error, ErrorType (netstandard2.0 / net10): implicit conversions, MemberNotNullWhen, no fluent API. Companion of the QG0100-QG0111 rules. |
KirillMaster.QualityGates.Results.AspNetCore |
ToHttpResult() ProblemDetails mapping and a ProblemExceptionHandler base that never exposes exception details. |
No StyleCop (Roslynator covers it). Legacy .NET Framework (net48) is not supported.
Result API caveats
Result<T>converts implicitly fromTand fromError;Result<Error>is therefore ambiguous, do not useErrorasT.ErrorTypeis closed by convention: add values only together with theToHttpResultmapping (the exhaustive switch fails the build on a missing member).- A
ResultorResult<T>must be used; QG0106 reports discarded and never-read results.
Usage
<GlobalPackageReference Include="KirillMaster.QualityGates.Build" Version="0.1.0" />
<PackageVersion Include="KirillMaster.QualityGates.Architecture" Version="0.1.0" />
Without central package management add <PackageReference Include="KirillMaster.QualityGates.Build" Version="0.1.0" PrivateAssets="all" /> to each project.
Copy templates/editorconfig.template from the package (or src/KirillMaster.QualityGates.Build/templates/) to .editorconfig in the repository root.
MSBuild contract
Property names are shared with the company package so one tool can drive both; the report format is described in docs/contract.md.
| Property | Default | Meaning |
|---|---|---|
QualityGatesEnabled |
true |
false turns everything off (set in Directory.Build.props or /p:) |
QualityGatesProfile |
strict |
strict = AnalysisLevel 10.0-all; legacy-ratchet = 10.0-recommended plus grandfathered suppressions |
QualityGatesMode |
adopt (env QUALITYGATES_MODE when empty) |
adopt compiles grandfathered suppressions; refactor does not compile them, forces TreatWarningsAsErrors=true and raises every QG* rule to error (see Refactor mode); the mode can also be set in the project body |
QualityGatesRefactorScope |
empty (all projects) | ; list of project-name prefixes (Shop.Orders*;Shop.Billing, blanks and spaces ignored, trailing * optional); projects outside the scope stay in adopt |
EnforceCodeStyleInBuild, AnalysisLevel |
true, pinned by profile |
defaults only; values set by the consumer win. AnalysisLevel is never latest so SDK upgrades do not change the rules |
QualityGatesLayer |
derived | core (*.Domain, *.Application, *.Core, *.Ports.Input, *.Ports.Output), test (*.Tests, relaxed test config), adapter |
QualityGatesBannedSymbols |
true |
false drops BannedSymbols.txt |
QualityGatesGlossary |
empty | path to a word list that must be named glossary.txt (one word per line or comma-separated, # comments); it is added as an AdditionalFile and activates QG0174 (unknown word). Without it QG0174 is silent |
QualityGatesGrandfatheredDir |
<repo>/quality/grandfathered/ |
folder with <Project>.GlobalSuppressions.cs files compiled into the project |
NuGet audit is not part of the package props (restore does not see them). Set it in Directory.Build.props:
<NuGetAuditMode>all</NuGetAuditMode>
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1903;NU1904;NU1905</WarningsNotAsErrors>
Analyzers
See docs/analyzers.md. Defaults are warnings, so TreatWarningsAsErrors makes them blocking. Thresholds are tunable in .editorconfig (qg_max_method_lines = 80). QG0031/QG0032 are opt-in.
Architecture tests
public sealed class Layering : HexagonalArchitectureTests
{
protected override IEnumerable<Assembly> Assemblies => [typeof(Shop.Domain.Order).Assembly, typeof(Shop.Application.IOrders).Assembly];
protected override ArchitectureConfig Config => ArchitectureConfig.Hexagonal("Shop");
}
public sealed class RepoRules : RepositoryRulesTests { }
ArchitectureConfig.FromFile("architecture.json") loads custom layers: { "layers": [ { "name": "Core", "pattern": "^X\\.Core(\\..*)?$", "mayDependOn": [], "forbiddenNamespaces": ["System.IO"] } ] }. Tests carry Category=Architecture for dotnet test --filter Category=Architecture.
Module and hexagonal rules (opt in)
Both are off until the base class overrides ModuleRulesConfig / HexagonalRulesConfig (tests Modules_follow_isolation_rules, Hexagonal_roles_follow_profile_rules). Violations carry a key and a measure; a violation is tolerated while a baseline entry with the same key has a measure at least as large ({"rules": {"FeatureIsolation": {"Bill->Fulfillment": 3}}}, RuleBaseline.From(violations).ToJson() generates it). Override FailOnStaleBaseline to also fail on entries that vanished or shrank, so the baseline only shrinks.
Generic module engine (ModulesConfig, baseline quality/modules-baseline.json). Modules are ordered regexes on the type namespace, then on the assembly name; kinds are Feature, Shared, Model, Composition.
| Rule | Checks | Default |
|---|---|---|
| FeatureIsolation | a feature depends on no other feature and no Composition module | soft (baseline); StrictFeatureIsolation ignores the baseline |
| SharedPurity | Shared depends on no Feature or Composition | on |
| SharedCap | types per Shared module | off (SharedMaxTypes) |
| CompositionCap | types per Composition module (root ratchet) | off (CompositionMaxTypes) |
| ModelIsLeaf | Model depends on no other module | on |
| ModuleFanOut | distinct modules a feature depends on | 6 (ModuleFanOutMax) |
| OrchestrationThin | orchestrator (OrchestratorPattern, default (Orchestrator|Coordinator)$) methods and distinct dependencies |
10 / 12 |
Hexagonal profile (HexagonalProfile, baseline quality/hexagonal-baseline.json). Roles come from regexes (namespace first, then assembly): *.Core*, *.Ports.Input, *.Ports.Output, *.Adapters.*, *.Runners.*, *.Host*; every pattern can be overridden.
| Rule | Checks | Default |
|---|---|---|
| AdapterReferencesAdapter | Adapters.A types use Adapters.B types |
on |
| AdapterDirection | Core and ports never use adapters, runners or host; adapters only the roles in AdaptersMayDependOn |
adapters: Core, both ports |
| RunnerHostReferences | runners use only RunnersMayDependOn (no level skipping) |
input ports |
| CoreNoContainer | DI container namespaces and IServiceProvider in Core and ports |
MS DI, Autofac, Ninject, StructureMap, Lamar, SimpleInjector, Windsor, Unity |
| PortWidth | members per port interface | 10 |
| NoMirrorTypes | Ports.Input and Ports.Output types sharing at least 90% of member name and type pairs (min 2 members) | 0.9 |
| PortNoTechnologyTypes | IQueryable, HTTP, JSON, Stream, System.Data, EF, Amazon, Npgsql types in port signatures |
on |
Use-case slice rules (opt in per rule)
List rule names in HexagonalProfile.ExtraRules (JSON: "extraRules": ["FlowDirection", "HandlerIsolation"]); they use the same baseline as the rules above, and unknown names throw. A handler is a non-abstract class implementing a Ports.Input interface that matches HandlerInterfacePattern (default ^I[A-Z]\w*Handler$); the suffix of the class does not matter. Flow levels are Runner, Handler, Domain (other Core), Port (Ports.Input and Ports.Output) and Adapter.
| Rule | Checks | Config |
|---|---|---|
| FlowDirection | nothing points back up the flow: Domain, ports and adapters never use a concrete handler; handlers, Domain, ports and adapters never use runners | roles |
| NoLevelSkipping (L5) | runners never use output ports or adapters; handlers, Domain and ports never use an adapter (only through an output port) | roles |
| HandlerIsolation | a handler uses no other handler class and no other use case's input port (its own port and decorators of it are fine) | HandlerInterfacePattern |
| ResultAtBoundary | a Result is only returned: no property, field or parameter of Result type outside ResultAllowedRoles |
ResultTypePattern, ResultAllowedRoles (Runners, Host) |
| OnePortPerUseCase | handler port has exactly one member (:members), one implementation, decorators of the same port excepted (:implementations); a handler implements one port (:ports) and has no public method beyond it (:public-methods) |
|
| FolderAllowlist | the feature folder under Core.Features, Ports.Input/Output, Adapters.X, Runners.X is not in ForbiddenFolders (Services, Helpers, Utils, Common, Shared, Mappers, Dtos, Contracts, Interfaces, Internal, Infrastructure, Base, ...) and, when Features is set, is a registered feature or in FolderVocabulary; works on namespaces |
FeatureFolderPattern, Features, FolderVocabulary, ForbiddenFolders |
| FeatureMirror | every Ports.Input and Ports.Output feature has a Core feature (Ports:X) and every Core feature has an input port feature (Core:X); does nothing without Core features |
CoreFeaturePattern, PortFeaturePattern |
| HandlerReferenced (Z2) | every handler class or its port is referenced from test code; include the test assemblies in Assemblies |
TestPattern |
StableDependencies (module engine, ModulesConfig) reports A->B when I(B) > I(A) + StableDependenciesEpsilon (0.15) with I = Ce / (Ca + Ce); modules with fewer than StableDependenciesMinCoupling (4) couplings are skipped. It is a warning (written to stderr by Modules_follow_isolation_rules, override ReportWarnings) until EnforceStableDependencies is true; then it fails on new entries like the other rules.
Convention and contract helpers (test classes)
Call these from your own tests; findings are RuleViolations, so RuleBaseline works on them.
| Helper | Gate | Checks |
|---|---|---|
ErrorConventions.SingleExceptionHandler(assemblies) |
X1 | exactly one non-abstract IExceptionHandler implementation in the host and composition assemblies |
ErrorConventions.UniqueErrorCodes(assemblies) |
X9 | codes (string constants or the Code of static Error values) are unique across all static *Errors classes |
EfConcurrency.MissingConcurrencyToken(dbContext.Model) |
Z13 | entities marked by markerPattern (default IAggregateRoot/AggregateRoot base, interface or attribute) have a property with IsConcurrencyToken; reflection over the model, no EF reference |
OpenApiSnapshot.Assert(json, path) |
H7, Y7 | generated OpenAPI JSON equals the committed snapshot (sorted keys, info.version and servers dropped); BreakingChanges(old, new) lists removed paths, operations, success responses, removed or retyped schema properties, removed enum values, new required parameters or properties |
MessageContractSnapshot.Assert(types, path) |
Y8 | same for JSON schemas of message types (Discover(assembly, regex) picks them); property names camelCase, enums as names |
Test types (TestPattern) have no role and are only the reference source of HandlerReferenced. DI registration classes (CompositionTypePattern, e.g. ServiceCollectionExtensions) are outside the flow levels; runners may use output port DTOs (the rule covers interfaces). Selected rules whose roles match no type throw instead of passing silently. Runner and Runners segments both mean runners; Core features live under Core.Features.X or Core.Internal.X. UniqueErrorCodes counts only members typed Error (errorTypePattern) and string members named *Code (codeMemberPattern); an unreadable member is a violation.
OpenAPI breaking changes are direction-aware (request: removed enum values, new required properties, narrowed nullability; response: added enum values, dropped required, removed properties, widened nullability; also format changes, oneOf/anyOf as sets, path-level parameters, renamed path templates). Relative snapshot paths resolve against the repository root (QG_REPO_ROOT, else .git or solution above the binaries). QG_UPDATE_SNAPSHOTS is ignored when CI is set.
Snapshot files are never rewritten silently: a missing or different file fails and lists the breaking changes; set QG_UPDATE_SNAPSHOTS=1 to write it, plus QG_ALLOW_BREAKING_CHANGES=1 when the diff contains breaking changes. The OpenAPI document comes from a WebApplicationFactory test (class T) or the build-time generator.
Optional package KirillMaster.QualityGates.Testing.AspNetCore
HostSmoke.AssertStartsAsync<Program>(configure) starts the host with test configuration (ValidateOnStart and DI validation run) and probes one path for non-5xx (Z14). RouteRecorder records the routes the tests hit (factory.WithWebHostBuilder(b => b.ConfigureServices(recorder.Install))) and AssertAllRoutesHit(factory.Services, known) fails for every route in the endpoint table that no test hit (infrastructure routes openapi, swagger, health ignored; known is the ratchet list). HostSmoke.AssertSingleExceptionHandler(factory.Services) checks X1 against the registered services. Targets net10.0 and requires Microsoft.AspNetCore.Mvc.Testing 10.x (the package depends on it; do not pin another major). Fallback endpoints (MapFallback) are not routes, and a 404 answered by a catch-all route or matching no endpoint is not a hit.
Refactoring and baseline keys
Keys name modules and types (Bill->Fulfillment, Shop.Core.IFat), so a move or rename makes the old entry stale and the same violation new. Remap the baseline in the same change: RuleBaseline.FromFile(path).Remap(new Dictionary<string, string> { ["Old.Type"] = "New.Type" }).ToJson() renames exact keys, keeps the measure (the larger one if two keys collapse) and leaves unmapped keys alone. Relative baseline paths need RepositoryRoot; without it the test throws instead of using an empty baseline. FailOnStaleBaseline only checks the rules the test ran, so one baseline file can be shared.
Legacy projects (ratchet)
Set QualityGatesProfile=legacy-ratchet, put existing violations into quality/grandfathered/<Project>.GlobalSuppressions.cs and record the count in quality/baseline.json ({"maxGrandfatheredSuppressions": 42}). Suppressions_stay_under_baseline fails if the count grows or if the baseline is stale, so it can only go down.
Type cycles
HexagonalArchitectureTests.Types_have_no_new_cycles finds cycles between types of the given assemblies (strongly connected components of the type dependency graph; compiler-generated types and nested-to-containing edges are ignored). Known cycles are tolerated through quality/type-cycles.json (ArchitectureConfig.TypeCycleBaselinePath): {"cycles": [["Shop.A", "Shop.B"]]}, one member set per cycle; a cycle that gains a member counts as new. TypeCycleBaseline.From(TypeCycles.Find(...)).ToJson() generates the file. Disable with CheckTypeCycles = false.
Refactor mode
QualityGatesMode=refactor (property in Directory.Build.props or the project, /p:, or env QUALITYGATES_MODE) does three things for in-scope projects:
- Stops compiling
quality/grandfathered/*.GlobalSuppressions.cs, so every grandfathered violation is an error again. - Sets
TreatWarningsAsErrors=true, even when the project setsfalse. - Adds
quality.refactor.globalconfig(global_level = 100), which sets allQualityGates.*analyzer categories (Abstraction, Design, Errors, Hygiene, Naming, Size, Testing) to error. This also enables the opt-in QG0031/QG0032. In every profile and mode QG rules are at least warnings, so the CI new-code gate sees them on changed lines; legacy code is covered by grandfathered suppressions. Curated third-party analyzers andAnalysisLevelare not changed (they already follow the profile); an.editorconfigentry still wins over the globalconfig.
Use QualityGatesRefactorScope to work on a few projects at a time; the rest keep adopt behaviour. Mode and scope are resolved in the package .targets, and TreatWarningsAsErrors is applied by the QualityGatesResolveMode target before compilation.
Gates and where they run
| Gate | Mechanism | Blocking |
|---|---|---|
| Build warnings as errors, analyzers | Build package | yes, every build |
| Format | dotnet format --verify-no-changes |
yes (CI job format) |
| Architecture | Architecture package tests | yes |
| Tests | dotnet test |
yes |
| Diff coverage >= 80 | diff-cover on cobertura, PR only (merge gate) |
yes, merge gate only |
| Mutation | Stryker.NET | never blocks (continue-on-error) |
See .github/workflows/ci.yml and samples/HexSample.
Develop
dotnet build -c Release
dotnet test -c Release
dotnet pack -c Release # writes artifacts/*.nupkg
Sample against the local feed: dotnet test samples/HexSample/HexSample.sln (restores from artifacts/).
Releasing
Publishing runs in .github/workflows/release.yml and authenticates to nuget.org with Trusted Publishing (OIDC, no API key stored anywhere). The nuget.org trusted publishing policy must match owner KirillMaster, repository quality-gates, workflow file release.yml.
One-time setup: add the repository variable NUGET_USER (Settings → Secrets and variables → Actions → Variables) set to your nuget.org profile name.
Per release: bump Version in Directory.Build.props, update CHANGELOG.md, then:
git tag v0.1.0 && git push origin v0.1.0
The workflow derives the package version from the tag (v0.1.0 → 0.1.0) and pushes with --skip-duplicate. Manual run: Actions → release → Run workflow, enter version.
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- IDisposableAnalyzers (>= 4.0.8)
- Meziantou.Analyzer (>= 3.0.294)
- Microsoft.CodeAnalysis.BannedApiAnalyzers (>= 5.6.0)
- Microsoft.VisualStudio.Threading.Analyzers (>= 18.7.23)
- Roslynator.Analyzers (>= 5.0.1)
- SonarAnalyzer.CSharp (>= 10.35.0.4138)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.