Files
git.stella-ops.org/.gitea/workflows/dead-path-detection.yml
2026-01-08 08:54:27 +02:00

440 lines
16 KiB
YAML

# .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: ${{ vars.LINUX_RUNNER_LABEL || 'ubuntu-latest' }}
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: ${{ vars.LINUX_RUNNER_LABEL || 'ubuntu-latest' }}
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}
---
<details>
<summary>How to fix dead paths</summary>
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
\`\`\`
</details>
`;
// 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
});
}