440 lines
16 KiB
YAML
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
|
|
});
|
|
}
|
|
|