Roslyn Analyzers: Enforcing the Chain at Compile Time
The MyApp.Requirements.Analyzers project is a Roslyn analyzer + source generator that enforces three invariants: every requirement is specified, every specification is implemented, and every implementation is tested. Violations are compiler errors or warnings -- not runtime failures.
Analyzer 1: Requirement Coverage (REQ1xx)
Scans Layer 1 (MyApp.Requirements) for all types inheriting RequirementMetadata with abstract AC methods. Scans Layer 2 (MyApp.Specifications) for [ForRequirement] attributes. Reports gaps.
| Diagnostic | Severity | Trigger |
|---|---|---|
| REQ100 | Error | Feature type has no [ForRequirement(typeof(Feature))] interface in Specifications |
| REQ101 | Error | Feature AC method has no matching [ForRequirement(..., nameof(AC))] on any spec interface method |
| REQ102 | Warning | Story type has no specification (acceptable for small stories, error in strict mode) |
| REQ103 | Info | Requirement fully specified -- all ACs have spec methods |
// Build output:
error REQ100: UserRolesFeature has 3 acceptance criteria but no ISpec interface
references it via [ForRequirement(typeof(UserRolesFeature))]
→ Create IUserRolesSpec with [ForRequirement(typeof(UserRolesFeature))]
error REQ101: UserRolesFeature.RoleChangeTakesEffectImmediately has no matching
spec method with [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a method to IUserRolesSpec with the matching [ForRequirement]// Build output:
error REQ100: UserRolesFeature has 3 acceptance criteria but no ISpec interface
references it via [ForRequirement(typeof(UserRolesFeature))]
→ Create IUserRolesSpec with [ForRequirement(typeof(UserRolesFeature))]
error REQ101: UserRolesFeature.RoleChangeTakesEffectImmediately has no matching
spec method with [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a method to IUserRolesSpec with the matching [ForRequirement]Analyzer 2: Specification Implementation (REQ2xx)
Scans Layer 2 interfaces decorated with [ForRequirement]. Scans Layer 3 (MyApp.Domain) for classes implementing those interfaces. Reports unimplemented specs.
| Diagnostic | Severity | Trigger |
|---|---|---|
| REQ200 | Error | Spec interface has no implementing class in Domain |
| REQ201 | Warning | Implementing class does not have [ForRequirement] attribute (works but loses traceability) |
| REQ202 | Warning | Implementing class has [ForRequirement] but method-level attributes are missing |
| REQ203 | Info | Spec fully implemented -- all methods have [ForRequirement] on impl |
// Build output:
error REQ200: IUserRolesSpec is not implemented by any class in MyApp.Domain
→ Create a class that implements IUserRolesSpec
warning REQ201: AuthorizationService implements IUserRolesSpec but is missing
[ForRequirement(typeof(UserRolesFeature))] on the class
→ Add [ForRequirement(typeof(UserRolesFeature))] for traceability
warning REQ202: AuthorizationService.VerifyImmediateRoleEffect implements
IUserRolesSpec but is missing [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add method-level [ForRequirement] for IDE navigation// Build output:
error REQ200: IUserRolesSpec is not implemented by any class in MyApp.Domain
→ Create a class that implements IUserRolesSpec
warning REQ201: AuthorizationService implements IUserRolesSpec but is missing
[ForRequirement(typeof(UserRolesFeature))] on the class
→ Add [ForRequirement(typeof(UserRolesFeature))] for traceability
warning REQ202: AuthorizationService.VerifyImmediateRoleEffect implements
IUserRolesSpec but is missing [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add method-level [ForRequirement] for IDE navigationAnalyzer 3: Test Coverage (REQ3xx)
Scans Layer 4 (MyApp.Tests) for [TestsFor] and [Verifies] attributes. Cross-references with Layer 1 AC methods. Reports untested ACs.
| Diagnostic | Severity | Trigger |
|---|---|---|
| REQ300 | Error | Feature has zero [TestsFor] test classes |
| REQ301 | Warning | AC method has no [Verifies(..., nameof(AC))] test |
| REQ302 | Warning | [Verifies] references an AC method that doesn't exist (stale test) |
| REQ303 | Info | Feature fully tested -- all ACs have at least one [Verifies] test |
// Build output:
error REQ300: JwtRefreshStory has 2 acceptance criteria but no test class
with [TestsFor(typeof(JwtRefreshStory))]
warning REQ301: UserRolesFeature.RoleChangeTakesEffectImmediately has no test
with [Verifies(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a test method with [Verifies] for this AC
warning REQ302: UserRolesTests.OldTest references nameof(UserRolesFeature.DeletedAC)
which no longer exists on UserRolesFeature
→ Remove or update the stale [Verifies] attribute// Build output:
error REQ300: JwtRefreshStory has 2 acceptance criteria but no test class
with [TestsFor(typeof(JwtRefreshStory))]
warning REQ301: UserRolesFeature.RoleChangeTakesEffectImmediately has no test
with [Verifies(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a test method with [Verifies] for this AC
warning REQ302: UserRolesTests.OldTest references nameof(UserRolesFeature.DeletedAC)
which no longer exists on UserRolesFeature
→ Remove or update the stale [Verifies] attributeAnalyzer 4: Quality Gates (REQ4xx)
Integrates with a dotnet quality-gates tool (invoked as an MSBuild target after test execution) to enforce that tests don't just exist -- they pass, meet coverage thresholds, and satisfy performance budgets.
MSBuild integration:
<!-- MyApp.Tests.csproj -->
<PropertyGroup>
<RequirementMinCoverage>80</RequirementMinCoverage>
<RequirementMinPassRate>100</RequirementMinPassRate>
<RequirementMaxTestDuration>5000</RequirementMaxTestDuration> <!-- ms per test -->
</PropertyGroup>
<Target Name="RequirementQualityGates" AfterTargets="VSTest">
<Exec Command="dotnet quality-gates check
--trx $(TestResultsDir)/*.trx
--coverage $(CoverageResultsDir)/coverage.cobertura.xml
--traceability $(IntermediateOutputPath)/TraceabilityMatrix.g.cs
--min-pass-rate $(RequirementMinPassRate)
--min-coverage $(RequirementMinCoverage)
--max-duration $(RequirementMaxTestDuration)"
ConsoleToMsBuild="true" />
</Target><!-- MyApp.Tests.csproj -->
<PropertyGroup>
<RequirementMinCoverage>80</RequirementMinCoverage>
<RequirementMinPassRate>100</RequirementMinPassRate>
<RequirementMaxTestDuration>5000</RequirementMaxTestDuration> <!-- ms per test -->
</PropertyGroup>
<Target Name="RequirementQualityGates" AfterTargets="VSTest">
<Exec Command="dotnet quality-gates check
--trx $(TestResultsDir)/*.trx
--coverage $(CoverageResultsDir)/coverage.cobertura.xml
--traceability $(IntermediateOutputPath)/TraceabilityMatrix.g.cs
--min-pass-rate $(RequirementMinPassRate)
--min-coverage $(RequirementMinCoverage)
--max-duration $(RequirementMaxTestDuration)"
ConsoleToMsBuild="true" />
</Target>What dotnet quality-gates check validates:
| Gate | What it checks | Fails when |
|---|---|---|
| Pass rate | All [Verifies] tests passed in .trx results |
Any test marked [Verifies] failed |
| AC coverage | Every AC in TraceabilityMatrix has a passing [Verifies] test |
AC exists but its test failed or is missing |
| Code coverage | Lines touched by [Verifies] tests cover the [ForRequirement] methods |
Implementation method has <N% line coverage |
| Duration | Individual test execution time | Any [Verifies] test exceeds max duration |
| Flakiness | Test result consistency across runs | A [Verifies] test has inconsistent pass/fail across retries |
| Fuzz testing | Auto-generated random/boundary inputs for AC methods | Implementation crashes or returns unexpected results on edge cases |
The fuzz testing gate deserves special attention. Because every AC is an abstract method with a typed signature, the tool knows exactly what inputs to generate. For AdminCanAssignRoles(UserId actingUser, UserId targetUser, RoleId role), it can automatically produce: null values, empty GUIDs, duplicate user/target IDs, nonexistent role IDs, extremely long strings, Unicode edge cases, and concurrent calls. The spec method's Result return type means the tool knows what "not crashing" looks like -- a Result.Failure with a reason is acceptable; an unhandled exception is not. This is property-based testing derived from the requirement signature itself.
Output:
$ dotnet quality-gates check --trx results.trx --coverage coverage.xml ...
Quality Gates Report
====================
FEATURE: UserRolesFeature (3 ACs, 5 tests)
✓ AdminCanAssignRoles 2 tests, 100% pass, 94% coverage, avg 12ms
fuzz: 500 inputs, 0 crashes, 12 Result.Failure (expected)
✓ ViewerHasReadOnlyAccess 2 tests, 100% pass, 88% coverage, avg 8ms
fuzz: 500 inputs, 0 crashes, 8 Result.Failure (expected)
✗ RoleChangeTakesEffect 1 test, 100% pass, 62% coverage ← BELOW 80% THRESHOLD
fuzz: 500 inputs, 1 CRASH ← NullReferenceException with empty RoleId
→ AuthorizationService.VerifyImmediateRoleEffect:L45-L52 not covered
STORY: JwtRefreshStory (2 ACs, 0 tests)
✗ TokensExpireAfterOneHour NO TESTS
✗ RefreshExtendsBySevenDays NO TESTS
Summary: 2/3 quality gates passed for UserRolesFeature (fuzz found 1 crash)
0/2 quality gates passed for JwtRefreshStory
Overall: FAIL (exit code 1)$ dotnet quality-gates check --trx results.trx --coverage coverage.xml ...
Quality Gates Report
====================
FEATURE: UserRolesFeature (3 ACs, 5 tests)
✓ AdminCanAssignRoles 2 tests, 100% pass, 94% coverage, avg 12ms
fuzz: 500 inputs, 0 crashes, 12 Result.Failure (expected)
✓ ViewerHasReadOnlyAccess 2 tests, 100% pass, 88% coverage, avg 8ms
fuzz: 500 inputs, 0 crashes, 8 Result.Failure (expected)
✗ RoleChangeTakesEffect 1 test, 100% pass, 62% coverage ← BELOW 80% THRESHOLD
fuzz: 500 inputs, 1 CRASH ← NullReferenceException with empty RoleId
→ AuthorizationService.VerifyImmediateRoleEffect:L45-L52 not covered
STORY: JwtRefreshStory (2 ACs, 0 tests)
✗ TokensExpireAfterOneHour NO TESTS
✗ RefreshExtendsBySevenDays NO TESTS
Summary: 2/3 quality gates passed for UserRolesFeature (fuzz found 1 crash)
0/2 quality gates passed for JwtRefreshStory
Overall: FAIL (exit code 1)Severity Configuration
The analyzer severity is configurable per-project via .editorconfig:
# .editorconfig
[*.cs]
# Requirements → Specifications: treat missing specs as errors
dotnet_diagnostic.REQ100.severity = error
dotnet_diagnostic.REQ101.severity = error
dotnet_diagnostic.REQ102.severity = warning
# Specifications → Implementation: treat missing impl as errors
dotnet_diagnostic.REQ200.severity = error
dotnet_diagnostic.REQ201.severity = warning
dotnet_diagnostic.REQ202.severity = suggestion
# Implementation → Tests: treat missing tests as warnings (errors in CI)
dotnet_diagnostic.REQ300.severity = warning
dotnet_diagnostic.REQ301.severity = warning
dotnet_diagnostic.REQ302.severity = error # stale tests are always errors# .editorconfig
[*.cs]
# Requirements → Specifications: treat missing specs as errors
dotnet_diagnostic.REQ100.severity = error
dotnet_diagnostic.REQ101.severity = error
dotnet_diagnostic.REQ102.severity = warning
# Specifications → Implementation: treat missing impl as errors
dotnet_diagnostic.REQ200.severity = error
dotnet_diagnostic.REQ201.severity = warning
dotnet_diagnostic.REQ202.severity = suggestion
# Implementation → Tests: treat missing tests as warnings (errors in CI)
dotnet_diagnostic.REQ300.severity = warning
dotnet_diagnostic.REQ301.severity = warning
dotnet_diagnostic.REQ302.severity = error # stale tests are always errorsFor CI, override to strict:
<!-- Directory.Build.props -->
<PropertyGroup Condition="'$(CI)' == 'true'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>REQ100;REQ101;REQ200;REQ300;REQ301</WarningsAsErrors>
</PropertyGroup><!-- Directory.Build.props -->
<PropertyGroup Condition="'$(CI)' == 'true'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>REQ100;REQ101;REQ200;REQ300;REQ301</WarningsAsErrors>
</PropertyGroup>The Full CI Pipeline
# .github/workflows/build.yml
jobs:
build:
steps:
- run: dotnet build --warnaserror # REQ1xx, REQ2xx, REQ3xx analyzers
- run: dotnet test --collect:"XPlat Code Coverage" --logger:trx
- run: dotnet quality-gates check # REQ4xx quality gates
--trx TestResults/*.trx
--coverage TestResults/**/coverage.cobertura.xml
--min-pass-rate 100
--min-coverage 80# .github/workflows/build.yml
jobs:
build:
steps:
- run: dotnet build --warnaserror # REQ1xx, REQ2xx, REQ3xx analyzers
- run: dotnet test --collect:"XPlat Code Coverage" --logger:trx
- run: dotnet quality-gates check # REQ4xx quality gates
--trx TestResults/*.trx
--coverage TestResults/**/coverage.cobertura.xml
--min-pass-rate 100
--min-coverage 80Three enforcement points, one build:
dotnet build-- Roslyn analyzers catch structural gaps (missing specs, missing implementations, missing tests)dotnet test-- tests execute against the implementationdotnet quality-gates-- post-test analysis ensures tests are passing, coverage is sufficient, and performance is acceptable
If any gate fails, the build fails. The chain is enforced from requirement to quality-verified production code.