save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

@@ -0,0 +1,208 @@
// <copyright file="BranchCoverageEnforcer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-014
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Testing.Coverage;
/// <summary>
/// Enforces minimum branch coverage and detects dead paths.
/// </summary>
public sealed class BranchCoverageEnforcer
{
private readonly CoverageReport _report;
private readonly BranchCoverageConfig _config;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="BranchCoverageEnforcer"/> class.
/// </summary>
/// <param name="report">Coverage report to analyze.</param>
/// <param name="config">Enforcement configuration.</param>
/// <param name="logger">Logger instance.</param>
public BranchCoverageEnforcer(
CoverageReport report,
BranchCoverageConfig? config = null,
ILogger? logger = null)
{
_report = report;
_config = config ?? new BranchCoverageConfig();
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}
/// <summary>
/// Verify branch coverage meets minimum threshold.
/// </summary>
/// <returns>Validation result.</returns>
public CoverageValidationResult Validate()
{
var violations = new List<CoverageViolation>();
foreach (var file in _report.Files)
{
// Skip excluded files
if (IsExcluded(file.Path))
{
_logger.LogDebug("Skipping excluded file: {Path}", file.Path);
continue;
}
// Check file-level coverage
if (file.BranchCoverage < _config.MinBranchCoverage)
{
var uncoveredLines = GetUncoveredBranches(file);
violations.Add(new CoverageViolation(
FilePath: file.Path,
Type: ViolationType.InsufficientCoverage,
ActualCoverage: file.BranchCoverage,
RequiredCoverage: _config.MinBranchCoverage,
UncoveredBranches: uncoveredLines));
_logger.LogWarning(
"Insufficient coverage in {Path}: {Actual:P1} < {Required:P1}",
file.Path, file.BranchCoverage, _config.MinBranchCoverage);
}
// Detect completely uncovered branches (dead paths)
if (_config.FailOnDeadPaths)
{
var deadPaths = file.Branches
.Where(b => b.HitCount == 0 && !IsExempt(file.Path, b.Line))
.ToList();
if (deadPaths.Count > 0)
{
violations.Add(new CoverageViolation(
FilePath: file.Path,
Type: ViolationType.DeadPath,
ActualCoverage: file.BranchCoverage,
RequiredCoverage: _config.MinBranchCoverage,
UncoveredBranches: [.. deadPaths.Select(b => b.Line)]));
_logger.LogWarning(
"Dead paths found in {Path}: {Count} uncovered branches",
file.Path, deadPaths.Count);
}
}
}
return new CoverageValidationResult(
IsValid: violations.Count == 0,
Violations: [.. violations],
OverallBranchCoverage: _report.OverallBranchCoverage);
}
/// <summary>
/// Generate report of dead paths for review.
/// </summary>
/// <returns>Dead path report.</returns>
public DeadPathReport GenerateDeadPathReport()
{
var deadPaths = new List<DeadPathEntry>();
foreach (var file in _report.Files)
{
if (IsExcluded(file.Path))
{
continue;
}
foreach (var branch in file.Branches.Where(b => b.HitCount == 0))
{
var isExempt = IsExempt(file.Path, branch.Line);
var exemptionReason = isExempt ? GetExemptionReason(file.Path, branch.Line) : null;
deadPaths.Add(new DeadPathEntry(
FilePath: file.Path,
Line: branch.Line,
BranchType: branch.Type,
IsExempt: isExempt,
ExemptionReason: exemptionReason));
}
}
return new DeadPathReport(
TotalDeadPaths: deadPaths.Count,
ExemptDeadPaths: deadPaths.Count(p => p.IsExempt),
ActiveDeadPaths: deadPaths.Count(p => !p.IsExempt),
Entries: [.. deadPaths]);
}
/// <summary>
/// Get a summary of coverage by directory.
/// </summary>
/// <returns>Dictionary of directory to coverage percentage.</returns>
public IReadOnlyDictionary<string, decimal> GetCoverageByDirectory()
{
var byDirectory = new Dictionary<string, List<decimal>>();
foreach (var file in _report.Files)
{
if (IsExcluded(file.Path))
{
continue;
}
var directory = Path.GetDirectoryName(file.Path) ?? ".";
if (!byDirectory.TryGetValue(directory, out var coverages))
{
coverages = [];
byDirectory[directory] = coverages;
}
coverages.Add(file.BranchCoverage);
}
return byDirectory.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Count > 0 ? kvp.Value.Average() : 0m);
}
/// <summary>
/// Get files below minimum coverage threshold.
/// </summary>
/// <returns>List of files below threshold.</returns>
public IReadOnlyList<FileCoverage> GetFilesBelowThreshold()
{
return _report.Files
.Where(f => !IsExcluded(f.Path) && f.BranchCoverage < _config.MinBranchCoverage)
.OrderBy(f => f.BranchCoverage)
.ToList();
}
private ImmutableArray<int> GetUncoveredBranches(FileCoverage file)
{
return [.. file.Branches
.Where(b => b.HitCount == 0)
.Select(b => b.Line)
.Distinct()
.OrderBy(l => l)];
}
private bool IsExcluded(string filePath)
{
return _config.ExcludePatterns.Any(p => p.IsMatch(filePath));
}
private bool IsExempt(string filePath, int line)
{
return _config.Exemptions.Any(e =>
e.FilePattern.IsMatch(filePath) &&
(e.Lines.IsDefaultOrEmpty || e.Lines.Contains(line)));
}
private string? GetExemptionReason(string filePath, int line)
{
var exemption = _config.Exemptions.FirstOrDefault(e =>
e.FilePattern.IsMatch(filePath) &&
(e.Lines.IsDefaultOrEmpty || e.Lines.Contains(line)));
return exemption?.Reason;
}
}

View File

@@ -0,0 +1,164 @@
// <copyright file="CoberturaParser.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-014
using System.Collections.Immutable;
using System.Globalization;
using System.Xml.Linq;
namespace StellaOps.Testing.Coverage;
/// <summary>
/// Parses Cobertura XML coverage reports.
/// </summary>
public static class CoberturaParser
{
/// <summary>
/// Parse a Cobertura XML file.
/// </summary>
/// <param name="filePath">Path to Cobertura XML file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Parsed coverage report.</returns>
public static async Task<CoverageReport> ParseFileAsync(string filePath, CancellationToken ct = default)
{
var xml = await File.ReadAllTextAsync(filePath, ct);
return Parse(xml);
}
/// <summary>
/// Parse a Cobertura XML string.
/// </summary>
/// <param name="xml">Cobertura XML content.</param>
/// <returns>Parsed coverage report.</returns>
public static CoverageReport Parse(string xml)
{
var doc = XDocument.Parse(xml);
var coverage = doc.Root ?? throw new InvalidOperationException("Invalid Cobertura XML: no root element");
var files = new List<FileCoverage>();
// Parse overall coverage
var lineCoverage = ParseDecimal(coverage.Attribute("line-rate")?.Value ?? "0");
var branchCoverage = ParseDecimal(coverage.Attribute("branch-rate")?.Value ?? "0");
// Parse timestamp
var timestamp = coverage.Attribute("timestamp")?.Value;
var generatedAt = timestamp != null
? DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp, CultureInfo.InvariantCulture))
: DateTimeOffset.UtcNow;
// Parse packages -> classes -> files
foreach (var package in coverage.Descendants("package"))
{
foreach (var cls in package.Descendants("class"))
{
var fileCoverage = ParseClass(cls);
if (fileCoverage != null)
{
files.Add(fileCoverage);
}
}
}
return new CoverageReport(
Files: [.. files],
OverallLineCoverage: lineCoverage,
OverallBranchCoverage: branchCoverage,
GeneratedAt: generatedAt);
}
private static FileCoverage? ParseClass(XElement cls)
{
var filename = cls.Attribute("filename")?.Value;
if (string.IsNullOrEmpty(filename))
{
return null;
}
var lineCoverage = ParseDecimal(cls.Attribute("line-rate")?.Value ?? "0");
var branchCoverage = ParseDecimal(cls.Attribute("branch-rate")?.Value ?? "0");
var lines = new List<LineCoverageData>();
var branches = new List<BranchCoverageData>();
var linesElement = cls.Element("lines");
if (linesElement != null)
{
foreach (var line in linesElement.Elements("line"))
{
var lineNumber = int.Parse(line.Attribute("number")?.Value ?? "0", CultureInfo.InvariantCulture);
var hits = int.Parse(line.Attribute("hits")?.Value ?? "0", CultureInfo.InvariantCulture);
var isBranch = line.Attribute("branch")?.Value == "true";
lines.Add(new LineCoverageData(
LineNumber: lineNumber,
HitCount: hits,
IsCoverable: true));
// Parse branch conditions if present
if (isBranch)
{
var conditionCoverage = line.Attribute("condition-coverage")?.Value;
var conditions = line.Element("conditions");
if (conditions != null)
{
var branchIndex = 0;
foreach (var condition in conditions.Elements("condition"))
{
var coverage = int.Parse(
condition.Attribute("coverage")?.Value ?? "0",
CultureInfo.InvariantCulture);
branches.Add(new BranchCoverageData(
Line: lineNumber,
BranchId: $"{lineNumber}-{branchIndex}",
Type: condition.Attribute("type")?.Value ?? "branch",
HitCount: coverage > 0 ? 1 : 0));
branchIndex++;
}
}
else if (conditionCoverage != null)
{
// Parse condition-coverage like "50% (1/2)"
var parts = conditionCoverage.Split(['(', '/', ')'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
var covered = int.Parse(parts[0].TrimEnd('%'), CultureInfo.InvariantCulture);
var total = int.Parse(parts[1], CultureInfo.InvariantCulture);
for (int i = 0; i < total; i++)
{
branches.Add(new BranchCoverageData(
Line: lineNumber,
BranchId: $"{lineNumber}-{i}",
Type: "branch",
HitCount: i < (covered * total / 100) ? 1 : 0));
}
}
}
}
}
}
return new FileCoverage(
Path: filename,
LineCoverage: lineCoverage,
BranchCoverage: branchCoverage,
Lines: [.. lines],
Branches: [.. branches]);
}
private static decimal ParseDecimal(string value)
{
if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result))
{
return result;
}
return 0m;
}
}

View File

@@ -0,0 +1,181 @@
// <copyright file="Models.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-013, CCUT-014
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Testing.Coverage;
/// <summary>
/// Coverage report for analysis.
/// </summary>
/// <param name="Files">Files with coverage data.</param>
/// <param name="OverallLineCoverage">Overall line coverage percentage.</param>
/// <param name="OverallBranchCoverage">Overall branch coverage percentage.</param>
/// <param name="GeneratedAt">When the report was generated.</param>
public sealed record CoverageReport(
ImmutableArray<FileCoverage> Files,
decimal OverallLineCoverage,
decimal OverallBranchCoverage,
DateTimeOffset GeneratedAt);
/// <summary>
/// Coverage data for a single file.
/// </summary>
/// <param name="Path">File path.</param>
/// <param name="LineCoverage">Line coverage percentage (0-1).</param>
/// <param name="BranchCoverage">Branch coverage percentage (0-1).</param>
/// <param name="Lines">Individual line coverage data.</param>
/// <param name="Branches">Individual branch coverage data.</param>
public sealed record FileCoverage(
string Path,
decimal LineCoverage,
decimal BranchCoverage,
ImmutableArray<LineCoverageData> Lines,
ImmutableArray<BranchCoverageData> Branches);
/// <summary>
/// Coverage data for a single line.
/// </summary>
/// <param name="LineNumber">Line number.</param>
/// <param name="HitCount">Number of times line was executed.</param>
/// <param name="IsCoverable">Whether line is coverable.</param>
public sealed record LineCoverageData(
int LineNumber,
int HitCount,
bool IsCoverable);
/// <summary>
/// Coverage data for a single branch.
/// </summary>
/// <param name="Line">Line number where branch occurs.</param>
/// <param name="BranchId">Branch identifier.</param>
/// <param name="Type">Type of branch (if/else, switch, etc.).</param>
/// <param name="HitCount">Number of times branch was taken.</param>
public sealed record BranchCoverageData(
int Line,
string BranchId,
string Type,
int HitCount);
/// <summary>
/// Configuration for branch coverage enforcement.
/// </summary>
/// <param name="MinBranchCoverage">Minimum required branch coverage (0-1).</param>
/// <param name="FailOnDeadPaths">Whether to fail on dead paths.</param>
/// <param name="Exemptions">Coverage exemptions.</param>
/// <param name="ExcludePatterns">File patterns to exclude from coverage analysis.</param>
public sealed record BranchCoverageConfig(
decimal MinBranchCoverage = 0.80m,
bool FailOnDeadPaths = true,
ImmutableArray<CoverageExemption> Exemptions = default,
ImmutableArray<Regex> ExcludePatterns = default)
{
/// <summary>
/// Gets exemptions with default empty array.
/// </summary>
public ImmutableArray<CoverageExemption> Exemptions { get; init; } =
Exemptions.IsDefault ? [] : Exemptions;
/// <summary>
/// Gets exclude patterns with default empty array.
/// </summary>
public ImmutableArray<Regex> ExcludePatterns { get; init; } =
ExcludePatterns.IsDefault ? GetDefaultExcludePatterns() : ExcludePatterns;
private static ImmutableArray<Regex> GetDefaultExcludePatterns()
{
return
[
new Regex(@"\.Tests\.cs$", RegexOptions.Compiled),
new Regex(@"\.Generated\.cs$", RegexOptions.Compiled),
new Regex(@"[\\/]obj[\\/]", RegexOptions.Compiled),
new Regex(@"[\\/]bin[\\/]", RegexOptions.Compiled),
new Regex(@"GlobalUsings\.cs$", RegexOptions.Compiled)
];
}
}
/// <summary>
/// A coverage exemption.
/// </summary>
/// <param name="FilePattern">Regex pattern matching file paths.</param>
/// <param name="Lines">Specific lines exempt (empty for all lines).</param>
/// <param name="Reason">Reason for exemption.</param>
public sealed record CoverageExemption(
Regex FilePattern,
ImmutableArray<int> Lines,
string Reason);
/// <summary>
/// Result of coverage validation.
/// </summary>
/// <param name="IsValid">Whether validation passed.</param>
/// <param name="Violations">List of violations found.</param>
/// <param name="OverallBranchCoverage">Overall branch coverage.</param>
public sealed record CoverageValidationResult(
bool IsValid,
ImmutableArray<CoverageViolation> Violations,
decimal OverallBranchCoverage);
/// <summary>
/// A coverage violation.
/// </summary>
/// <param name="FilePath">File with violation.</param>
/// <param name="Type">Type of violation.</param>
/// <param name="ActualCoverage">Actual coverage percentage.</param>
/// <param name="RequiredCoverage">Required coverage percentage.</param>
/// <param name="UncoveredBranches">Lines with uncovered branches.</param>
public sealed record CoverageViolation(
string FilePath,
ViolationType Type,
decimal ActualCoverage,
decimal RequiredCoverage,
ImmutableArray<int> UncoveredBranches);
/// <summary>
/// Type of coverage violation.
/// </summary>
public enum ViolationType
{
/// <summary>
/// Coverage below minimum threshold.
/// </summary>
InsufficientCoverage,
/// <summary>
/// Dead path detected (branch never taken).
/// </summary>
DeadPath
}
/// <summary>
/// A dead path entry.
/// </summary>
/// <param name="FilePath">File containing dead path.</param>
/// <param name="Line">Line number.</param>
/// <param name="BranchType">Type of branch.</param>
/// <param name="IsExempt">Whether this path is exempt.</param>
/// <param name="ExemptionReason">Reason for exemption if applicable.</param>
public sealed record DeadPathEntry(
string FilePath,
int Line,
string BranchType,
bool IsExempt,
string? ExemptionReason);
/// <summary>
/// Report of dead paths found in codebase.
/// </summary>
/// <param name="TotalDeadPaths">Total number of dead paths.</param>
/// <param name="ExemptDeadPaths">Number of exempt dead paths.</param>
/// <param name="ActiveDeadPaths">Number of active (non-exempt) dead paths.</param>
/// <param name="Entries">Individual dead path entries.</param>
public sealed record DeadPathReport(
int TotalDeadPaths,
int ExemptDeadPaths,
int ActiveDeadPaths,
ImmutableArray<DeadPathEntry> Entries);

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<UseAppHost>true</UseAppHost>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>true</IsPackable>
<Description>Branch coverage enforcement and dead-path detection framework</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Testing.Coverage.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="xunit.v3.assert" PrivateAssets="all" />
<PackageReference Include="xunit.v3.core" PrivateAssets="all" />
</ItemGroup>
</Project>