Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Ops.Quality -- Code Quality Thresholds as Compiler Diagnostics

"We will address tech debt next quarter." -- every quarter, forever.


The Problem

The SonarQube dashboard is green. Nobody has looked at it in three weeks.

The quality gate was set when the project started: cyclomatic complexity under 20, line coverage above 70%, no critical vulnerabilities. Those thresholds were reasonable then. The project has grown. The OrderPriceCalculator class has a method with complexity 34. Nobody noticed because the quality gate is a post-build step in a CI pipeline that sends a Slack notification to a channel with 847 unread messages.

Technical debt is worse. The team estimated 120 hours of tech debt at the start of the quarter. They budgeted 15 hours per sprint for remediation. Three sprints later, the debt is at 180 hours because new features added faster than the burndown consumed. Nobody noticed because the debt budget is a row in a spreadsheet that the tech lead updates manually.

Coverage targets are the same story. The README says "80% line coverage." The actual coverage is 62%. The gap grew by 1-2% per sprint. The number is available in the CI output, but it is not a build failure, so it is not a blocker, so it is not fixed.

The pattern: quality thresholds are defined outside the codebase, monitored by external tools, and enforced by human discipline. When the humans get busy -- and they always get busy -- the thresholds drift.

The Quality DSL moves thresholds into the codebase as attributes. The source generator emits config files for external tools. The analyzer enforces thresholds at build time. The build fails before the dashboard turns red.


QualityDimension and QualityAction Enums

public enum QualityDimension
{
    CyclomaticComplexity,
    CognitiveComplexity,
    Duplication,
    TechDebtRatio,
    LineCoverage,
    BranchCoverage,
    MutationScore,
    DocumentationCoverage,
    DependencyFreshness
}

public enum QualityAction
{
    /// <summary>Emit a compiler warning.</summary>
    Warn,

    /// <summary>Emit a compiler error. Build fails.</summary>
    Block,

    /// <summary>Emit a build notification (MSBuild message).</summary>
    Notify
}

Nine dimensions. Three enforcement levels. Every quality concern fits into this matrix.

QualityGate

[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class,
    AllowMultiple = true)]
public sealed class QualityGateAttribute : Attribute
{
    public QualityGateAttribute(
        QualityDimension dimension,
        double threshold) { }

    /// <summary>
    /// What happens when the threshold is violated.
    /// </summary>
    public QualityAction OnViolation { get; init; } = QualityAction.Block;

    /// <summary>
    /// Optional scope — if set, applies only to methods/types matching this pattern.
    /// If null, applies to the entire assembly or the decorated type.
    /// </summary>
    public string? Scope { get; init; }

    /// <summary>
    /// Grace period in days before the gate becomes enforced.
    /// Useful when tightening thresholds on existing code.
    /// </summary>
    public int GracePeriodDays { get; init; } = 0;
}

The gate is applied at assembly level (global threshold) or class level (per-type override). The GracePeriodDays is critical for brownfield projects -- you can tighten a threshold and give the team 30 days to comply before the build breaks.

TechDebtBudget

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class TechDebtBudgetAttribute : Attribute
{
    public TechDebtBudgetAttribute(int maxHours) { }

    /// <summary>
    /// Hours of debt to burn per sprint.
    /// </summary>
    public double BurndownRate { get; init; }

    /// <summary>
    /// Percentage of budget consumed before alerting. 0–100.
    /// </summary>
    public int AlertAtPercent { get; init; } = 80;

    /// <summary>
    /// Sprint length in days for burndown calculation.
    /// </summary>
    public int SprintLengthDays { get; init; } = 14;

    /// <summary>
    /// Action when budget is exceeded.
    /// </summary>
    public QualityAction OnExceeded { get; init; } = QualityAction.Block;
}

The debt budget is an assembly-level attribute because debt is a team concern, not a class concern. The burndown rate is the team's commitment per sprint. When accumulated debt exceeds maxHours, the build action fires.

CoverageTarget

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class CoverageTargetAttribute : Attribute
{
    /// <summary>
    /// Minimum line coverage percentage (0–100).
    /// </summary>
    public double Line { get; init; } = 80;

    /// <summary>
    /// Minimum branch coverage percentage (0–100).
    /// </summary>
    public double Branch { get; init; } = 70;

    /// <summary>
    /// Minimum mutation score percentage (0–100).
    /// Requires Stryker.NET or equivalent.
    /// </summary>
    public double Mutation { get; init; } = 0;

    /// <summary>
    /// Path to the coverage report file (Cobertura XML or OpenCover).
    /// </summary>
    public string? CoverageReportPath { get; init; }

    /// <summary>
    /// Action when coverage falls below target.
    /// </summary>
    public QualityAction OnViolation { get; init; } = QualityAction.Block;
}

Usage: OrderService Quality Gates

// Assembly-level quality gates — apply to the entire project
[assembly: QualityGate(QualityDimension.CyclomaticComplexity, 15,
    OnViolation = QualityAction.Block)]

[assembly: QualityGate(QualityDimension.CognitiveComplexity, 20,
    OnViolation = QualityAction.Warn)]

[assembly: QualityGate(QualityDimension.Duplication, 3.0,
    OnViolation = QualityAction.Warn,
    Scope = "*.Services.*")]

[assembly: QualityGate(QualityDimension.DependencyFreshness, 180,
    OnViolation = QualityAction.Notify)]

[assembly: CoverageTarget(
    Line = 80,
    Branch = 70,
    Mutation = 60,
    CoverageReportPath = "TestResults/coverage.cobertura.xml")]

[assembly: TechDebtBudget(200,
    BurndownRate = 10,
    SprintLengthDays = 14,
    AlertAtPercent = 80,
    OnExceeded = QualityAction.Block)]

Six declarations. The entire quality policy for the OrderService project. This replaces:

  • The SonarQube quality gate configuration (a web UI nobody bookmarked).
  • The coverage threshold in a CI YAML file.
  • The tech debt budget in a spreadsheet.
  • The complexity rule in a .editorconfig that nobody knows exists.

Per-Type Overrides

Global gates are defaults. Some types need tighter or looser thresholds:

// The price calculator is critical — tighter constraints
[QualityGate(QualityDimension.CyclomaticComplexity, 10,
    OnViolation = QualityAction.Block)]
[QualityGate(QualityDimension.CognitiveComplexity, 12,
    OnViolation = QualityAction.Block)]
public class OrderPriceCalculator
{
    // ...
}

// The legacy adapter is being migrated — grace period
[QualityGate(QualityDimension.CyclomaticComplexity, 25,
    OnViolation = QualityAction.Warn,
    GracePeriodDays = 90)]
public class LegacyOrderAdapter
{
    // ...
}

The grace period is calculated from the attribute's first appearance in git history. The source generator emits a comment with the enforcement date so the team knows the deadline.


quality-gates.json (SonarQube-Compatible)

{
  "name": "OrderService-QualityGate",
  "conditions": [
    {
      "metric": "complexity",
      "op": "GT",
      "error": "15"
    },
    {
      "metric": "cognitive_complexity",
      "op": "GT",
      "warning": "20"
    },
    {
      "metric": "duplicated_lines_density",
      "op": "GT",
      "warning": "3.0"
    },
    {
      "metric": "line_coverage",
      "op": "LT",
      "error": "80"
    },
    {
      "metric": "branch_coverage",
      "op": "LT",
      "error": "70"
    }
  ]
}

One source of truth (the C# attributes), two outputs (compiler diagnostics and SonarQube config). The SonarQube dashboard reflects the same thresholds as the build. They cannot drift because they are generated from the same attributes.

.editorconfig Entries

# <auto-generated from [QualityGate] attributes>

# Cyclomatic complexity
dotnet_diagnostic.CA1502.severity = error
dotnet_code_quality.CA1502.max_complexity = 15

# Cognitive complexity (if using SonarAnalyzer)
dotnet_diagnostic.S3776.severity = warning
dotnet_code_quality.S3776.threshold = 20

Teams that do not use SonarQube still get enforcement. The .editorconfig entries configure Roslyn analyzers that ship with the .NET SDK.

TechDebtDashboard.g.md

# Tech Debt Dashboard
Generated: 2026-04-06

## Budget
| Metric              | Value     |
|---------------------|-----------|
| Max Budget          | 200h      |
| Current Debt        | 165h      |
| Budget Used         | 82.5%     |
| Alert Threshold     | 80%       |
| Status              | ALERT     |
| Burndown Rate       | 10h/sprint|
| Sprint Length        | 14 days   |
| Sprints to Clear    | 16.5      |

## Burndown Projection
| Sprint | Projected Debt |
|--------|---------------|
| S1     | 155h          |
| S2     | 145h          |
| S3     | 135h          |
| S4     | 125h          |
| S5     | 115h          |
| ...    | ...           |
| S17    | 0h            |

## Debt Items (from TODO/HACK/FIXME comments with [TechDebt] tags)
| ID      | Description                           | Est. Hours | File                      |
|---------|---------------------------------------|-----------|---------------------------|
| TD-001  | Replace legacy XML parser             | 40h       | LegacyOrderAdapter.cs     |
| TD-002  | Extract pricing rules to strategy     | 20h       | OrderPriceCalculator.cs   |
| TD-003  | Remove nullable suppression operators | 8h        | OrderRepository.cs        |
| ...     | ...                                   | ...       | ...                       |

The dashboard is generated into the build output. It answers three questions: "How much debt do we have?", "Are we on track to reduce it?", and "What are the specific items?"


QLT001: Method Exceeding Complexity

error QLT001: Method 'CalculateDiscount' in 'OrderPriceCalculator' has
cyclomatic complexity 18, exceeding the threshold of 10 set by
[QualityGate(CyclomaticComplexity, 10)].

The analyzer computes cyclomatic complexity using the Roslyn control flow graph API. It checks the method's complexity against the nearest applicable threshold: class-level attribute first, then assembly-level fallback.

The complexity is computed during compilation, not by a post-build tool. This means the red squiggly appears in the IDE as the developer types. Feedback in seconds, not minutes.

QLT002: Project Below Coverage

error QLT002: Line coverage is 62.3%, below the target of 80% set by
[CoverageTarget(Line = 80)]. Coverage report:
TestResults/coverage.cobertura.xml

This analyzer runs as a post-build step (Stage 5 generator) that reads the Cobertura XML report and compares against the declared thresholds. It only fires if the coverage report file exists -- the absence of the file generates a separate warning.

QLT003: Tech Debt Over Budget

error QLT003: Tech debt is estimated at 165h, exceeding 80% of the 200h
budget set by [TechDebtBudget(200, AlertAtPercent = 80)]. Current burn
rate (10h/sprint) projects 16.5 sprints to clear.

The debt estimator counts // TODO, // HACK, // FIXME comments and [TechDebt] attributes, aggregates the estimated hours, and compares against the budget. The projection uses the declared burndown rate to calculate sprints-to-clear.


Quality to Requirements

The Requirements DSL defines features with acceptance criteria. The Quality DSL can set coverage targets per feature:

// Requirements DSL
[Feature("FEAT-456", "Flash Sale Pricing")]

// Quality DSL — this specific feature needs higher coverage
[assembly: QualityGate(QualityDimension.LineCoverage, 95,
    OnViolation = QualityAction.Block,
    Scope = "*.FlashSale.*")]

The scope filter uses namespace patterns to apply tighter thresholds to critical features. The generator cross-references the Requirements DSL feature map to produce per-feature coverage reports.

Quality to Testing

The Quality DSL and Testing DSL have a natural dependency. If the Quality DSL declares a mutation score threshold, the Testing DSL must declare mutation testing as a required category:

// Quality DSL says: mutation score >= 60%
[assembly: CoverageTarget(Mutation = 60)]

// Testing DSL must say: mutation testing is required
[TestStrategy(typeof(OrderService),
    Required = TestCategory.Mutation)]

// If TestStrategy does not include Mutation but CoverageTarget
// requires it, analyzer QLT-TST001 fires:
// "CoverageTarget requires Mutation score of 60% but no
//  [TestStrategy] for 'OrderService' includes TestCategory.Mutation"

This cross-DSL analyzer ensures that quality targets and test strategies are aligned. You cannot require a mutation score without requiring mutation tests. The two DSLs validate each other.

Quality to Observability

The Quality DSL feeds runtime dashboards through the Observability DSL (Part 7):

// Quality generates a Prometheus metric
// ops_quality_complexity_current{class="OrderPriceCalculator"} 18
// ops_quality_coverage_line_current{project="OrderService"} 62.3
// ops_quality_techdebt_hours_current{assembly="OrderService"} 165

The Observability DSL's [Dashboard] attribute can reference quality metrics, creating Grafana panels that show complexity trends, coverage trends, and debt burndown alongside operational metrics. Quality is not separate from operations -- it is an operational concern.


Why Not Just Use SonarQube?

SonarQube is a good tool. The Quality DSL is not a replacement. It is a declaration layer that sits above SonarQube (or any other quality tool).

The difference is where the source of truth lives:

Approach Source of Truth Enforcement Drift Risk
SonarQube alone Web UI config Post-build gate High
Quality DSL + SonarQube C# attributes Compile-time + post-build Zero

With the Quality DSL, the SonarQube configuration is a generated artifact, not a manually configured one. The C# attributes are the single source. If a developer changes the threshold in code, the SonarQube config file is regenerated on the next build. If someone changes the SonarQube config in the UI, it gets overwritten.

The build fails in the IDE. The SonarQube dashboard confirms it. Two feedback channels, one source of truth, zero drift.

⬇ Download