tests pipeline run attempt
This commit is contained in:
@@ -1,439 +0,0 @@
|
||||
# .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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user