# .gitea/workflows/dead-path-detection.yml # Dead-path detection workflow for uncovered branch identification # Sprint: SPRINT_20260105_002_005_TEST_cross_cutting # Task: CCUT-017 # # WORKFLOW PURPOSE: # ================= # Detects uncovered code paths (dead paths) by analyzing branch coverage data. # Compares against baseline exemptions and fails on new dead paths to prevent # coverage regression and identify potential unreachable code. # # Coverage collection uses Coverlet with Cobertura output format. name: Dead-Path Detection on: push: branches: [main] paths: - 'src/**/*.cs' - 'src/**/*.csproj' - '.gitea/workflows/dead-path-detection.yml' pull_request: paths: - 'src/**/*.cs' - 'src/**/*.csproj' workflow_dispatch: inputs: update_baseline: description: 'Update the dead-path baseline' type: boolean default: false coverage_threshold: description: 'Branch coverage threshold (%)' type: number default: 80 env: DOTNET_VERSION: '10.0.100' DOTNET_NOLOGO: 1 DOTNET_CLI_TELEMETRY_OPTOUT: 1 COVERAGE_OUTPUT: './coverage' DEFAULT_THRESHOLD: 80 jobs: # =========================================================================== # COLLECT COVERAGE AND DETECT DEAD PATHS # =========================================================================== detect: name: Detect Dead Paths runs-on: ubuntu-22.04 outputs: has-new-dead-paths: ${{ steps.check.outputs.has_new_dead_paths }} new-dead-path-count: ${{ steps.check.outputs.new_count }} total-dead-paths: ${{ steps.check.outputs.total_count }} branch-coverage: ${{ steps.coverage.outputs.branch_coverage }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- - name: Restore Dependencies run: dotnet restore src/StellaOps.sln - name: Run Tests with Coverage id: test run: | mkdir -p ${{ env.COVERAGE_OUTPUT }} # Run tests with branch coverage collection dotnet test src/StellaOps.sln \ --configuration Release \ --no-restore \ --verbosity minimal \ --collect:"XPlat Code Coverage" \ --results-directory ${{ env.COVERAGE_OUTPUT }} \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.IncludeTestAssembly=false # Merge coverage reports if multiple exist if command -v reportgenerator &> /dev/null; then reportgenerator \ -reports:"${{ env.COVERAGE_OUTPUT }}/**/coverage.cobertura.xml" \ -targetdir:"${{ env.COVERAGE_OUTPUT }}/merged" \ -reporttypes:"Cobertura" fi - name: Calculate Branch Coverage id: coverage run: | # Find coverage file COVERAGE_FILE=$(find ${{ env.COVERAGE_OUTPUT }} -name "coverage.cobertura.xml" | head -1) if [ -z "$COVERAGE_FILE" ]; then echo "::warning::No coverage file found" echo "branch_coverage=0" >> $GITHUB_OUTPUT exit 0 fi # Extract branch coverage from Cobertura XML BRANCH_RATE=$(grep -oP 'branch-rate="\K[^"]+' "$COVERAGE_FILE" | head -1) BRANCH_COVERAGE=$(echo "scale=2; $BRANCH_RATE * 100" | bc) echo "Branch coverage: ${BRANCH_COVERAGE}%" echo "branch_coverage=$BRANCH_COVERAGE" >> $GITHUB_OUTPUT - name: Detect Dead Paths id: detect run: | # Find coverage file COVERAGE_FILE=$(find ${{ env.COVERAGE_OUTPUT }} -name "coverage.cobertura.xml" | head -1) if [ -z "$COVERAGE_FILE" ]; then echo "::warning::No coverage file found, skipping dead-path detection" echo '{"activeDeadPaths": 0, "entries": []}' > dead-paths-report.json exit 0 fi # Parse coverage and extract uncovered branches cat > extract-dead-paths.py << 'SCRIPT' import xml.etree.ElementTree as ET import json import sys import os def extract_dead_paths(coverage_file, exemptions_file=None): tree = ET.parse(coverage_file) root = tree.getroot() exemptions = set() if exemptions_file and os.path.exists(exemptions_file): with open(exemptions_file) as f: import yaml data = yaml.safe_load(f) or {} exemptions = set(data.get('exemptions', [])) dead_paths = [] for package in root.findall('.//package'): for cls in package.findall('.//class'): filename = cls.get('filename', '') classname = cls.get('name', '') for line in cls.findall('.//line'): branch = line.get('branch', 'false') if branch != 'true': continue hits = int(line.get('hits', 0)) line_num = int(line.get('number', 0)) condition = line.get('condition-coverage', '') # Parse condition coverage (e.g., "50% (1/2)") if condition: import re match = re.search(r'\((\d+)/(\d+)\)', condition) if match: covered = int(match.group(1)) total = int(match.group(2)) if covered < total: path_id = f"{filename}:{line_num}" is_exempt = path_id in exemptions dead_paths.append({ 'file': filename, 'line': line_num, 'class': classname, 'coveredBranches': covered, 'totalBranches': total, 'coverage': f"{covered}/{total}", 'isExempt': is_exempt, 'pathId': path_id }) # Sort by file and line dead_paths.sort(key=lambda x: (x['file'], x['line'])) active_count = len([p for p in dead_paths if not p['isExempt']]) report = { 'activeDeadPaths': active_count, 'totalDeadPaths': len(dead_paths), 'exemptedPaths': len(dead_paths) - active_count, 'entries': dead_paths } return report if __name__ == '__main__': coverage_file = sys.argv[1] if len(sys.argv) > 1 else 'coverage.cobertura.xml' exemptions_file = sys.argv[2] if len(sys.argv) > 2 else None report = extract_dead_paths(coverage_file, exemptions_file) with open('dead-paths-report.json', 'w') as f: json.dump(report, f, indent=2) print(f"Found {report['activeDeadPaths']} active dead paths") print(f"Total uncovered branches: {report['totalDeadPaths']}") print(f"Exempted: {report['exemptedPaths']}") SCRIPT python3 extract-dead-paths.py "$COVERAGE_FILE" "coverage-exemptions.yaml" - name: Load Baseline id: baseline run: | # Check for baseline file if [ -f "dead-paths-baseline.json" ]; then BASELINE_COUNT=$(jq '.activeDeadPaths // 0' dead-paths-baseline.json) echo "baseline_count=$BASELINE_COUNT" >> $GITHUB_OUTPUT echo "has_baseline=true" >> $GITHUB_OUTPUT else echo "baseline_count=0" >> $GITHUB_OUTPUT echo "has_baseline=false" >> $GITHUB_OUTPUT echo "::notice::No baseline file found. First run will establish baseline." fi - name: Check for New Dead Paths id: check run: | CURRENT_COUNT=$(jq '.activeDeadPaths' dead-paths-report.json) BASELINE_COUNT=${{ steps.baseline.outputs.baseline_count }} TOTAL_COUNT=$(jq '.totalDeadPaths' dead-paths-report.json) # Calculate new dead paths (only count increases) if [ "$CURRENT_COUNT" -gt "$BASELINE_COUNT" ]; then NEW_COUNT=$((CURRENT_COUNT - BASELINE_COUNT)) HAS_NEW="true" else NEW_COUNT=0 HAS_NEW="false" fi echo "has_new_dead_paths=$HAS_NEW" >> $GITHUB_OUTPUT echo "new_count=$NEW_COUNT" >> $GITHUB_OUTPUT echo "total_count=$TOTAL_COUNT" >> $GITHUB_OUTPUT echo "Current active dead paths: $CURRENT_COUNT" echo "Baseline: $BASELINE_COUNT" echo "New dead paths: $NEW_COUNT" if [ "$HAS_NEW" = "true" ]; then echo "::error::Found $NEW_COUNT new dead paths since baseline" # Show top 10 new dead paths echo "" echo "=== New Dead Paths ===" jq -r '.entries | map(select(.isExempt == false)) | .[:10][] | "\(.file):\(.line) - \(.coverage) branches covered"' dead-paths-report.json exit 1 else echo "No new dead paths detected." fi - name: Check Coverage Threshold if: always() run: | THRESHOLD=${{ inputs.coverage_threshold || env.DEFAULT_THRESHOLD }} COVERAGE=${{ steps.coverage.outputs.branch_coverage }} if [ -z "$COVERAGE" ] || [ "$COVERAGE" = "0" ]; then echo "::warning::Could not determine branch coverage" exit 0 fi # Compare coverage to threshold BELOW_THRESHOLD=$(echo "$COVERAGE < $THRESHOLD" | bc) if [ "$BELOW_THRESHOLD" -eq 1 ]; then echo "::warning::Branch coverage ($COVERAGE%) is below threshold ($THRESHOLD%)" else echo "Branch coverage ($COVERAGE%) meets threshold ($THRESHOLD%)" fi - name: Update Baseline if: inputs.update_baseline == true && github.event_name == 'workflow_dispatch' run: | cp dead-paths-report.json dead-paths-baseline.json echo "Baseline updated with current dead paths" - name: Generate Report if: always() run: | # Generate markdown report cat > dead-paths-report.md << EOF ## Dead-Path Detection Report | Metric | Value | |--------|-------| | Branch Coverage | ${{ steps.coverage.outputs.branch_coverage }}% | | Active Dead Paths | $(jq '.activeDeadPaths' dead-paths-report.json) | | Total Uncovered Branches | $(jq '.totalDeadPaths' dead-paths-report.json) | | Exempted Paths | $(jq '.exemptedPaths' dead-paths-report.json) | | Baseline | ${{ steps.baseline.outputs.baseline_count }} | | New Dead Paths | ${{ steps.check.outputs.new_count }} | ### Top Uncovered Files EOF # Add top files by dead path count jq -r ' .entries | group_by(.file) | map({file: .[0].file, count: length}) | sort_by(-.count) | .[:10][] | "| \(.file) | \(.count) |" ' dead-paths-report.json >> dead-paths-report.md 2>/dev/null || true echo "" >> dead-paths-report.md echo "*Report generated at $(date -u +%Y-%m-%dT%H:%M:%SZ)*" >> dead-paths-report.md - name: Upload Reports if: always() uses: actions/upload-artifact@v4 with: name: dead-path-reports path: | dead-paths-report.json dead-paths-report.md if-no-files-found: ignore - name: Upload Coverage if: always() uses: actions/upload-artifact@v4 with: name: coverage-report path: ${{ env.COVERAGE_OUTPUT }} if-no-files-found: ignore # =========================================================================== # POST REPORT TO PR # =========================================================================== comment: name: Post Report needs: detect if: github.event_name == 'pull_request' && always() runs-on: ubuntu-22.04 permissions: pull-requests: write steps: - name: Download Report uses: actions/download-artifact@v4 with: name: dead-path-reports continue-on-error: true - name: Post Comment uses: actions/github-script@v7 with: script: | const fs = require('fs'); let report = ''; try { report = fs.readFileSync('dead-paths-report.md', 'utf8'); } catch (e) { report = 'Dead-path report not available.'; } const hasNewDeadPaths = '${{ needs.detect.outputs.has-new-dead-paths }}' === 'true'; const newCount = '${{ needs.detect.outputs.new-dead-path-count }}'; const branchCoverage = '${{ needs.detect.outputs.branch-coverage }}'; const status = hasNewDeadPaths ? ':x: Failed' : ':white_check_mark: Passed'; const body = `## Dead-Path Detection ${status} ${hasNewDeadPaths ? `Found **${newCount}** new dead path(s) that need coverage.` : 'No new dead paths detected.'} **Branch Coverage:** ${branchCoverage}% ${report} ---
How to fix dead paths Dead paths are code branches that are never executed during tests. To fix: 1. **Add tests** that exercise the uncovered branches 2. **Remove dead code** if the branch is truly unreachable 3. **Add exemption** if the code is intentionally untested (document reason) Example exemption in \`coverage-exemptions.yaml\`: \`\`\`yaml exemptions: - "src/Module/File.cs:42" # Emergency handler - tested manually \`\`\`
`; // Find existing comment const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }); const botComment = comments.find(c => c.user.type === 'Bot' && c.body.includes('Dead-Path Detection') ); if (botComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: body }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: body }); }