diff --git a/.github/workflows/libc-full-coverage.yml b/.github/workflows/libc-full-coverage.yml new file mode 100644 index 0000000000000..7604907b75d69 --- /dev/null +++ b/.github/workflows/libc-full-coverage.yml @@ -0,0 +1,152 @@ +name: Libc Full Codebase Coverage + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +on: + # Daily overnight run at 02:00 UTC + schedule: + - cron: '0 2 * * *' + + # Allow manual on-demand execution from GitHub Actions UI + workflow_dispatch: + + # Trigger on pushes to main + push: + branches: + - main + paths: + - 'libc/**' + - '.github/workflows/libc-full-coverage.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + full-coverage: + timeout-minutes: 60 + name: libc-full-coverage + runs-on: ubuntu-24.04 + container: + image: ghcr.io/llvm/libc-ubuntu-24.04:latest@sha256:8fee4c9ce5a1fd095686593cd36e032c48a31b6ae575378c82accd1d86a08d59 + defaults: + run: + shell: bash + steps: + - name: Checkout Code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Setup Compiler Cache (sccache) + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 + with: + max-size: 1G + key: libc_coverage_unified_v2_x86_64 + variant: sccache + + - name: Configure CMake + run: | + export CMAKE_FLAGS=" + -G Ninja + -S runtimes + -B build-cov + -DCMAKE_C_COMPILER=clang-23 + -DCMAKE_CXX_COMPILER=clang++-23 + -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_C_COMPILER_LAUNCHER=sccache + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + -DLLVM_USE_LINKER=lld-23 + -DLLVM_ENABLE_RUNTIMES=libc + -DLLVM_LIBC_FULL_BUILD=ON + -DLIBC_ENABLE_COVERAGE=ON + -DLIBC_TEST_UNIT_TEST_ONLY=ON + -DLIBC_TEST_SKIP_DEATH_TESTS=ON + -DLIBC_TEST_SKIP_SHARED_TESTS=ON + " + cmake $CMAKE_FLAGS + + - name: Run Full Codebase Unit Tests + run: | + export LLVM_PROFILE_FILE="libc_cov_%p.profraw" + ninja -k 0 -C build-cov libc-unit-tests || true + + - name: Merge Profiles + run: | + find . -name "libc_cov_*.profraw" > profraw_list.txt + NUM_PROFS=$(wc -l < profraw_list.txt || echo 0) + echo "[LOG] Discovered $NUM_PROFS raw profile data files." + if [ -s profraw_list.txt ]; then + llvm-profdata-23 merge -sparse --input-files=profraw_list.txt -o libc_full.profdata + echo "[LOG] Merged full codebase profile into libc_full.profdata." + else + echo "[LOG] Warning: No profraw files found." + fi + + - name: Generate Reports and Summary + env: + COMMIT_SHA: ${{ github.sha }} + BRANCH_REF: ${{ github.ref_name }} + run: | + EXECUTABLES=($(find build-cov -type f -executable -name "*__build__")) + OBJECTS=("${EXECUTABLES[@]:1}") + OBJECTS=("${OBJECTS[@]/#/-object=}") + + echo "[LOG] Exporting coverage data across ${#EXECUTABLES[@]} test binaries." + + if [ ! -f libc_full.profdata ] || [ ${#EXECUTABLES[@]} -eq 0 ]; then + echo "[LOG] Warning: Profile data or test executables missing. Skipping report generation." + echo "### LLVM-libc Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "No coverage data was generated for this run." >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # 1. Generate JSON export for Full Coverage Analyzer + llvm-cov-23 export -format=text -instr-profile=libc_full.profdata "${EXECUTABLES[0]}" "${OBJECTS[@]}" > coverage.json + + # 2. Generate HTML Coverage Report with directory hierarchy and branch tracking + llvm-cov-23 show -format=html \ + -output-dir=coverage_html \ + -instr-profile=libc_full.profdata \ + "${EXECUTABLES[0]}" "${OBJECTS[@]}" \ + --show-directory-coverage \ + --show-branches=count \ + --compilation-dir=. \ + --path-equivalence="$GITHUB_WORKSPACE,." \ + -ignore-filename-regex=".*(test|utils).*" + # 3. Run Codebase Coverage Analyzer + PYTHONPATH="libc/utils/coverage" python3 libc/utils/coverage/codebase_coverage.py coverage.json "$COMMIT_SHA" "$BRANCH_REF" > coverage_summary.md + cat coverage_summary.md >> $GITHUB_STEP_SUMMARY + echo "[LOG] Post-commit summary report written to GITHUB_STEP_SUMMARY." + + - name: Fallback Failure Summary + if: failure() && !hashFiles('coverage.json') + run: | + echo "### LLVM-libc Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "Workflow run encountered an error before coverage reports could be generated. Inspect job logs for details." >> $GITHUB_STEP_SUMMARY + + - name: Upload Coverage Summary Artifact + if: always() && hashFiles('coverage_summary.md') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libc-coverage-summary + path: | + coverage_summary.md + coverage.json + if-no-files-found: ignore + retention-days: 14 + overwrite: true + + - name: Upload HTML Coverage Artifact + if: always() && hashFiles('coverage_html/**') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libc-coverage-html + path: coverage_html/ + retention-days: 14 + overwrite: true diff --git a/.github/workflows/libc-full-mcdc.yml b/.github/workflows/libc-full-mcdc.yml new file mode 100644 index 0000000000000..9aa6d96943891 --- /dev/null +++ b/.github/workflows/libc-full-mcdc.yml @@ -0,0 +1,157 @@ +name: Libc Full Codebase MC/DC Coverage + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +on: + # Daily overnight run at 02:30 UTC (staggered by 30 mins from baseline sweep) + schedule: + - cron: '30 2 * * *' + + # Allow manual on-demand execution from GitHub Actions UI ("Run workflow" button) + workflow_dispatch: + + # Trigger on pushes to main + push: + branches: + - main + paths: + - 'libc/**' + - '.github/workflows/libc-full-mcdc.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + full-mcdc-coverage: + timeout-minutes: 60 + name: libc-full-mcdc-coverage + runs-on: ubuntu-24.04 + container: + image: ghcr.io/llvm/libc-ubuntu-24.04:latest@sha256:8fee4c9ce5a1fd095686593cd36e032c48a31b6ae575378c82accd1d86a08d59 + defaults: + run: + shell: bash + steps: + - name: Checkout Code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Setup Compiler Cache (sccache) + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 + with: + max-size: 1G + key: libc_mcdc_coverage_unified_x86_64 + variant: sccache + + - name: Configure CMake with MC/DC + run: | + export CMAKE_FLAGS=" + -G Ninja + -S runtimes + -B build-cov + -DCMAKE_C_COMPILER=clang-23 + -DCMAKE_CXX_COMPILER=clang++-23 + -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_C_COMPILER_LAUNCHER=sccache + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + -DLLVM_USE_LINKER=lld-23 + -DLLVM_ENABLE_RUNTIMES=libc + -DLLVM_LIBC_FULL_BUILD=ON + -DLIBC_ENABLE_COVERAGE=ON + -DLIBC_ENABLE_MCDC=ON + -DLIBC_TEST_UNIT_TEST_ONLY=ON + -DLIBC_TEST_SKIP_DEATH_TESTS=ON + -DLIBC_TEST_SKIP_SHARED_TESTS=ON + " + cmake $CMAKE_FLAGS + + - name: Run Full Codebase Unit Tests + run: | + export LLVM_PROFILE_FILE="libc_cov_%p.profraw" + ninja -k 0 -C build-cov libc-unit-tests || true + + - name: Merge Profiles + run: | + find . -name "libc_cov_*.profraw" > profraw_list.txt + NUM_PROFS=$(wc -l < profraw_list.txt || echo 0) + echo "[LOG] Discovered $NUM_PROFS raw profile data files." + if [ -s profraw_list.txt ]; then + llvm-profdata-23 merge -sparse --input-files=profraw_list.txt -o libc_full.profdata + echo "[LOG] Merged full codebase profile into libc_full.profdata." + else + echo "[LOG] Warning: No profraw files found." + fi + + - name: Generate Reports and Summary + env: + COMMIT_SHA: ${{ github.sha }} + BRANCH_REF: ${{ github.ref_name }} + run: | + EXECUTABLES=($(find build-cov -type f -executable -name "*__build__")) + OBJECTS=("${EXECUTABLES[@]:1}") + OBJECTS=("${OBJECTS[@]/#/-object=}") + + echo "[LOG] Exporting coverage data across ${#EXECUTABLES[@]} test binaries." + + if [ ! -f libc_full.profdata ] || [ ${#EXECUTABLES[@]} -eq 0 ]; then + echo "[LOG] Warning: Profile data or test executables missing. Skipping report generation." + echo "### LLVM-libc MC/DC Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "No coverage data was generated for this run." >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # 1. Generate JSON export for Full Coverage Analyzer (contains mcdc_records) + llvm-cov-23 export -format=text -instr-profile=libc_full.profdata "${EXECUTABLES[0]}" "${OBJECTS[@]}" > coverage.json + + # 2. Generate HTML Coverage Report with directory hierarchy, branch tracking, and MC/DC truth tables + llvm-cov-23 show -format=html \ + -output-dir=coverage_mcdc_html \ + -instr-profile=libc_full.profdata \ + "${EXECUTABLES[0]}" "${OBJECTS[@]}" \ + --show-directory-coverage \ + --show-branches=count \ + --show-mcdc \ + --show-mcdc-summary \ + --compilation-dir=. \ + --path-equivalence="$GITHUB_WORKSPACE,." \ + -ignore-filename-regex=".*(test|utils).*" + # 3. Run Codebase Coverage Analyzer + PYTHONPATH="libc/utils/coverage" python3 libc/utils/coverage/codebase_coverage.py coverage.json "$COMMIT_SHA" "$BRANCH_REF" > coverage_summary.md + cat coverage_summary.md >> $GITHUB_STEP_SUMMARY + echo "[LOG] Post-commit summary report written to GITHUB_STEP_SUMMARY." + + - name: Fallback Failure Summary + if: failure() && !hashFiles('coverage.json') + run: | + echo "### LLVM-libc MC/DC Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "Workflow run encountered an error before coverage reports could be generated. Inspect job logs for details." >> $GITHUB_STEP_SUMMARY + + - name: Upload MC/DC Coverage Summary Artifact + if: always() && hashFiles('coverage_summary.md') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libc-mcdc-coverage-summary + path: | + coverage_summary.md + coverage.json + if-no-files-found: ignore + retention-days: 14 + overwrite: true + + - name: Upload MC/DC Coverage Artifact + if: always() && hashFiles('coverage_mcdc_html/**') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libc-mcdc-coverage-html + path: coverage_mcdc_html/ + retention-days: 14 + overwrite: true + + diff --git a/.github/workflows/libc-patch-coverage.yml b/.github/workflows/libc-patch-coverage.yml new file mode 100644 index 0000000000000..c5637301575ea --- /dev/null +++ b/.github/workflows/libc-patch-coverage.yml @@ -0,0 +1,298 @@ +name: Libc Patch Code Coverage + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'libc/src/**' + - 'libc/include/**' + - 'libc/test/**' + - 'libc/CMakeLists.txt' + - '.github/workflows/libc-patch-coverage.yml' + - '!libc/docs/**' + - '!libc/benchmarks/**' + - '!libc/fuzzing/**' + - '!libc/utils/**' + - '!**.md' + pull_request: + branches: + - main + paths: + - 'libc/src/**' + - 'libc/include/**' + - 'libc/test/**' + - 'libc/CMakeLists.txt' + - '.github/workflows/libc-patch-coverage.yml' + - '!libc/docs/**' + - '!libc/benchmarks/**' + - '!libc/fuzzing/**' + - '!libc/utils/**' + - '!**.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pre-commit-coverage: + timeout-minutes: 60 + name: libc-pre-commit-coverage + runs-on: ubuntu-24.04 + container: + image: ghcr.io/llvm/libc-ubuntu-24.04:latest@sha256:8fee4c9ce5a1fd095686593cd36e032c48a31b6ae575378c82accd1d86a08d59 + defaults: + run: + shell: bash + steps: + - name: Checkout Code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 100 + persist-credentials: false + + - name: Setup Compiler Cache (sccache) + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 + with: + max-size: 1G + key: libc_coverage_unified_v2_x86_64 + variant: sccache + + - name: Configure CMake + run: | + export CMAKE_FLAGS=" + -G Ninja + -S runtimes + -B build-cov + -DCMAKE_C_COMPILER=clang-23 + -DCMAKE_CXX_COMPILER=clang++-23 + -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_C_COMPILER_LAUNCHER=sccache + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + -DLLVM_USE_LINKER=lld-23 + -DLLVM_ENABLE_RUNTIMES=libc + -DLLVM_LIBC_FULL_BUILD=ON + -DLIBC_ENABLE_COVERAGE=ON + -DLIBC_TEST_UNIT_TEST_ONLY=ON + -DLIBC_TEST_SKIP_DEATH_TESTS=ON + -DLIBC_TEST_SKIP_SHARED_TESTS=ON + " + cmake $CMAKE_FLAGS + + - name: Build and Run Targeted Tests + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + COMMIT_SHA: ${{ github.sha }} + BRANCH_REF: ${{ github.ref_name }} + REPO_NAME: ${{ github.repository }} + run: | + # Ensure upstream llvm-project remote is configured + git remote add upstream https://github.com/llvm/llvm-project.git 2>/dev/null || true + + # 1. Deterministically resolve Base and Head references against upstream llvm-project + if [ "$EVENT_NAME" == "pull_request" ]; then + BASE_SHA="$PR_BASE_SHA" + HEAD_SHA="$PR_HEAD_SHA" + BASE_REF="$PR_BASE_REF" + HEAD_REF="$PR_HEAD_REF" + BASE_REPO="${PR_BASE_REPO:-llvm/llvm-project}" + HEAD_REPO="${PR_HEAD_REPO:-$REPO_NAME}" + # Fetch base branch history to resolve merge base + git fetch upstream "$BASE_REF" --depth=100 2>/dev/null || git fetch origin "$BASE_REF" --depth=100 2>/dev/null || git fetch upstream "$BASE_SHA" --depth=1 2>/dev/null || git fetch origin "$BASE_SHA" --depth=1 2>/dev/null || true + DIFF_BASE=$(git merge-base "$BASE_SHA" HEAD 2>/dev/null || true) + if [ -z "$DIFF_BASE" ]; then + git fetch --no-tags --deepen=200 2>/dev/null || true + DIFF_BASE=$(git merge-base "$BASE_SHA" HEAD 2>/dev/null || echo "$BASE_SHA") + fi + elif [ "$BRANCH_REF" != "main" ]; then + # For feature branches, compare against main merge-base + git fetch origin main --depth=100 2>/dev/null || true + BASE_SHA=$(git merge-base origin/main HEAD 2>/dev/null || git rev-parse HEAD~1 2>/dev/null || echo "HEAD~1") + HEAD_SHA="$COMMIT_SHA" + BASE_REF="main" + HEAD_REF="$BRANCH_REF" + BASE_REPO="llvm/llvm-project" + HEAD_REPO="$REPO_NAME" + DIFF_BASE="$BASE_SHA" + else + # For direct pushes, compare the pushed commit against its parent (HEAD~1) + BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "HEAD~1") + HEAD_SHA="$COMMIT_SHA" + BASE_REF="main" + HEAD_REF="$BRANCH_REF" + BASE_REPO="llvm/llvm-project" + HEAD_REPO="$REPO_NAME" + DIFF_BASE="HEAD~1" + fi + + # Persist resolved commit references for all downstream steps + echo "DIFF_BASE=$DIFF_BASE" >> $GITHUB_ENV + echo "BASE_SHA=$BASE_SHA" >> $GITHUB_ENV + echo "HEAD_SHA=$HEAD_SHA" >> $GITHUB_ENV + echo "BASE_REF=$BASE_REF" >> $GITHUB_ENV + echo "HEAD_REF=$HEAD_REF" >> $GITHUB_ENV + echo "BASE_REPO=$BASE_REPO" >> $GITHUB_ENV + echo "HEAD_REPO=$HEAD_REPO" >> $GITHUB_ENV + + echo "[LOG] Resolved Base: ${BASE_REF} (${BASE_SHA:0:7}) in ${BASE_REPO}" + echo "[LOG] Resolved Head: ${HEAD_REF} (${HEAD_SHA:0:7}) in ${HEAD_REPO}" + + MODIFIED_FILES=$(git diff --name-only "$DIFF_BASE" HEAD -- libc/src/ || true) + echo "[LOG] Modified files in libc/src/:" + echo "$MODIFIED_FILES" + + if [ -z "$MODIFIED_FILES" ]; then + echo "[LOG] No source files modified in libc/src/. Exiting successfully." + echo "TARGETS=" >> $GITHUB_ENV + + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO}/commit/${BASE_SHA})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO}/commit/${HEAD_SHA})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "No \`.cpp\` source files in \`libc/src/\` were modified in this patch." >> coverage_report.md + + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # Get all valid ninja targets + ninja -C build-cov -t targets > all_targets.txt + + TARGETS="" + + for FILE in $MODIFIED_FILES; do + # Skip core support files from direct target mapping (tested transitively) + if [[ "$FILE" == *"__support/"* ]]; then + echo "[LOG] -> Support file ($FILE) will be tested transitively." + continue + fi + + if [[ "$FILE" =~ ^libc/src/([^/]+)/(.*/)?([^/]+)\.cpp$ ]]; then + DIR="${BASH_REMATCH[1]}" + FUNC="${BASH_REMATCH[3]}" + ALL_MATCHES=$(grep -oE "^libc\.test\.src\.${DIR}\.([a-zA-Z0-9_]+\.)*${FUNC}_test\.__unit__" all_targets.txt || true) + FOUND_TARGET=$(echo "$ALL_MATCHES" | head -n 1 | tr -d '\r\n ' || true) + if [ -n "$FOUND_TARGET" ]; then + echo "[LOG] -> Matched $FILE -> $FOUND_TARGET" + TARGETS="$TARGETS $FOUND_TARGET" + else + echo "[LOG] -> Notice: No direct unit test target found for $FILE." + fi + fi + done + + # Remove duplicate targets + TARGETS=$(echo "$TARGETS" | xargs -n1 | sort -u | xargs || true) + echo "[LOG] Executing Ninja targets: $TARGETS" + + if [ -n "$TARGETS" ]; then + export LLVM_PROFILE_FILE="libc_cov_%p.profraw" + ninja -C build-cov $TARGETS + echo "TARGETS=$TARGETS" >> $GITHUB_ENV + else + echo "[LOG] No standalone unit test targets to run." + echo "TARGETS=" >> $GITHUB_ENV + + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO}/commit/${BASE_SHA})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO}/commit/${HEAD_SHA})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "Modified files do not have standalone unit test targets." >> coverage_report.md + + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + fi + + - name: Merge Profiles + if: env.TARGETS != '' + run: | + find . -name "libc_cov_*.profraw" > profraw_list.txt + NUM_PROFS=$(wc -l < profraw_list.txt || echo 0) + echo "[LOG] Discovered $NUM_PROFS raw profile data files." + if [ -s profraw_list.txt ]; then + llvm-profdata-23 merge -sparse --input-files=profraw_list.txt -o libc_full.profdata + echo "[LOG] Successfully merged profile data into libc_full.profdata." + else + echo "[LOG] Warning: No profraw files found." + fi + + - name: Extract Executables and Generate Summary + if: env.TARGETS != '' + run: | + EXECUTABLES=($(find build-cov -type f -executable -name "*__build__")) + OBJECTS=("${EXECUTABLES[@]:1}") + OBJECTS=("${OBJECTS[@]/#/-object=}") + + echo "[LOG] Discovered ${#EXECUTABLES[@]} instrumented binaries for llvm-cov export." + + if [ ! -f libc_full.profdata ] || [ ${#EXECUTABLES[@]} -eq 0 ]; then + echo "[LOG] Notice: Profile data or instrumented executables not found. Generating non-coverage summary." + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO}/commit/${BASE_SHA})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO}/commit/${HEAD_SHA})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "No executable statement profile data was collected for the modified lines in this patch." >> coverage_report.md + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # 1. Generate JSON report for Python script + llvm-cov-23 export -format=text -instr-profile=libc_full.profdata "${EXECUTABLES[0]}" "${OBJECTS[@]}" > coverage.json + + # 2. Generate git diff for modified libc source files + git diff "$DIFF_BASE" HEAD -- libc/src/ > patch.diff + + # 3. Run Diff Coverage Python Script using persisted commit metadata + PYTHONPATH="libc/utils/coverage" python3 libc/utils/coverage/diff_coverage.py patch.diff coverage.json "$BASE_SHA" "$HEAD_SHA" "$BASE_REF" "$HEAD_REF" "$TARGETS" "$BASE_REPO" "$HEAD_REPO" > coverage_report.md + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + echo "[LOG] Coverage report successfully written to GITHUB_STEP_SUMMARY." + + - name: Fallback Failure Summary + if: failure() && !hashFiles('coverage_report.md') + run: | + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF:-main}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO:-llvm/llvm-project}/commit/${BASE_SHA:-main})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF:-HEAD}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO:-llvm/llvm-project}/commit/${HEAD_SHA:-HEAD})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "The targeted build or test execution encountered an error before coverage data could be finalized. Inspect the job logs for details." >> coverage_report.md + + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + + + - name: Upload Patch Coverage Artifacts + if: always() && hashFiles('coverage_report.md') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libc-patch-coverage-report + path: | + coverage_report.md + patch.diff + coverage.json + if-no-files-found: ignore + retention-days: 14 + overwrite: true diff --git a/.github/workflows/libc-patch-mcdc.yml b/.github/workflows/libc-patch-mcdc.yml new file mode 100644 index 0000000000000..c4e6c238e4b8d --- /dev/null +++ b/.github/workflows/libc-patch-mcdc.yml @@ -0,0 +1,298 @@ +name: Libc Patch MC/DC Coverage + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'libc/src/**' + - 'libc/include/**' + - 'libc/test/**' + - 'libc/CMakeLists.txt' + - '.github/workflows/libc-patch-mcdc.yml' + - '!libc/docs/**' + - '!libc/benchmarks/**' + - '!libc/fuzzing/**' + - '!libc/utils/**' + - '!**.md' + pull_request: + branches: + - main + paths: + - 'libc/src/**' + - 'libc/include/**' + - 'libc/test/**' + - 'libc/CMakeLists.txt' + - '.github/workflows/libc-patch-mcdc.yml' + - '!libc/docs/**' + - '!libc/benchmarks/**' + - '!libc/fuzzing/**' + - '!libc/utils/**' + - '!**.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pre-commit-mcdc-coverage: + timeout-minutes: 60 + name: libc-pre-commit-mcdc-coverage + runs-on: ubuntu-24.04 + container: + image: ghcr.io/llvm/libc-ubuntu-24.04:latest@sha256:8fee4c9ce5a1fd095686593cd36e032c48a31b6ae575378c82accd1d86a08d59 + defaults: + run: + shell: bash + steps: + - name: Checkout Code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 100 + persist-credentials: false + + - name: Setup Compiler Cache (sccache) + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 + with: + max-size: 1G + key: libc_coverage_unified_v2_x86_64 + variant: sccache + + - name: Configure CMake + run: | + export CMAKE_FLAGS=" + -G Ninja + -S runtimes + -B build-cov + -DCMAKE_C_COMPILER=clang-23 + -DCMAKE_CXX_COMPILER=clang++-23 + -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_C_COMPILER_LAUNCHER=sccache + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + -DLLVM_USE_LINKER=lld-23 + -DLLVM_ENABLE_RUNTIMES=libc + -DLLVM_LIBC_FULL_BUILD=ON + -DLIBC_ENABLE_COVERAGE=ON + -DLIBC_ENABLE_MCDC=ON + -DLIBC_TEST_UNIT_TEST_ONLY=ON + -DLIBC_TEST_SKIP_DEATH_TESTS=ON + -DLIBC_TEST_SKIP_SHARED_TESTS=ON + " + cmake $CMAKE_FLAGS + + - name: Build and Run Targeted Tests + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + COMMIT_SHA: ${{ github.sha }} + BRANCH_REF: ${{ github.ref_name }} + REPO_NAME: ${{ github.repository }} + run: | + # Ensure upstream llvm-project remote is configured + git remote add upstream https://github.com/llvm/llvm-project.git 2>/dev/null || true + + # 1. Deterministically resolve Base and Head references against upstream llvm-project + if [ "$EVENT_NAME" == "pull_request" ]; then + BASE_SHA="$PR_BASE_SHA" + HEAD_SHA="$PR_HEAD_SHA" + BASE_REF="$PR_BASE_REF" + HEAD_REF="$PR_HEAD_REF" + BASE_REPO="${PR_BASE_REPO:-llvm/llvm-project}" + HEAD_REPO="${PR_HEAD_REPO:-$REPO_NAME}" + # Fetch base branch history to resolve merge base + git fetch upstream "$BASE_REF" --depth=100 2>/dev/null || git fetch origin "$BASE_REF" --depth=100 2>/dev/null || git fetch upstream "$BASE_SHA" --depth=1 2>/dev/null || git fetch origin "$BASE_SHA" --depth=1 2>/dev/null || true + DIFF_BASE=$(git merge-base "$BASE_SHA" HEAD 2>/dev/null || true) + if [ -z "$DIFF_BASE" ]; then + git fetch --no-tags --deepen=200 2>/dev/null || true + DIFF_BASE=$(git merge-base "$BASE_SHA" HEAD 2>/dev/null || echo "$BASE_SHA") + fi + elif [ "$BRANCH_REF" != "main" ]; then + # For feature branches, compare against main merge-base + git fetch origin main --depth=100 2>/dev/null || true + BASE_SHA=$(git merge-base origin/main HEAD 2>/dev/null || git rev-parse HEAD~1 2>/dev/null || echo "HEAD~1") + HEAD_SHA="$COMMIT_SHA" + BASE_REF="main" + HEAD_REF="$BRANCH_REF" + BASE_REPO="llvm/llvm-project" + HEAD_REPO="$REPO_NAME" + DIFF_BASE="$BASE_SHA" + else + BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "HEAD~1") + HEAD_SHA="$COMMIT_SHA" + BASE_REF="main" + HEAD_REF="$BRANCH_REF" + BASE_REPO="llvm/llvm-project" + HEAD_REPO="$REPO_NAME" + DIFF_BASE="HEAD~1" + fi + + # Persist resolved commit references for all downstream steps + echo "DIFF_BASE=$DIFF_BASE" >> $GITHUB_ENV + echo "BASE_SHA=$BASE_SHA" >> $GITHUB_ENV + echo "HEAD_SHA=$HEAD_SHA" >> $GITHUB_ENV + echo "BASE_REF=$BASE_REF" >> $GITHUB_ENV + echo "HEAD_REF=$HEAD_REF" >> $GITHUB_ENV + echo "BASE_REPO=$BASE_REPO" >> $GITHUB_ENV + echo "HEAD_REPO=$HEAD_REPO" >> $GITHUB_ENV + + echo "[LOG] Resolved Base: ${BASE_REF} (${BASE_SHA:0:7}) in ${BASE_REPO}" + echo "[LOG] Resolved Head: ${HEAD_REF} (${HEAD_SHA:0:7}) in ${HEAD_REPO}" + + MODIFIED_FILES=$(git diff --name-only "$DIFF_BASE" HEAD -- libc/src/ || true) + echo "[LOG] Modified files in libc/src/:" + echo "$MODIFIED_FILES" + + if [ -z "$MODIFIED_FILES" ]; then + echo "[LOG] No source files modified in libc/src/. Exiting successfully." + echo "TARGETS=" >> $GITHUB_ENV + + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO}/commit/${BASE_SHA})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO}/commit/${HEAD_SHA})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "No \`.cpp\` source files in \`libc/src/\` were modified in this patch." >> coverage_report.md + + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # Get all valid ninja targets + ninja -C build-cov -t targets > all_targets.txt + + TARGETS="" + + for FILE in $MODIFIED_FILES; do + # Skip core support files from direct target mapping (tested transitively) + if [[ "$FILE" == *"__support/"* ]]; then + echo "[LOG] -> Support file ($FILE) will be tested transitively." + continue + fi + + if [[ "$FILE" =~ ^libc/src/([^/]+)/(.*/)?([^/]+)\.cpp$ ]]; then + DIR="${BASH_REMATCH[1]}" + FUNC="${BASH_REMATCH[3]}" + ALL_MATCHES=$(grep -oE "^libc\.test\.src\.${DIR}\.([a-zA-Z0-9_]+\.)*${FUNC}_test\.__unit__" all_targets.txt || true) + FOUND_TARGET=$(echo "$ALL_MATCHES" | head -n 1 | tr -d '\r\n ' || true) + if [ -n "$FOUND_TARGET" ]; then + echo "[LOG] -> Matched $FILE -> $FOUND_TARGET" + TARGETS="$TARGETS $FOUND_TARGET" + else + echo "[LOG] -> Notice: No direct unit test target found for $FILE." + fi + fi + done + + # Remove duplicate targets + TARGETS=$(echo "$TARGETS" | xargs -n1 | sort -u | xargs || true) + echo "[LOG] Executing Ninja targets: $TARGETS" + + if [ -n "$TARGETS" ]; then + export LLVM_PROFILE_FILE="libc_cov_%p.profraw" + ninja -C build-cov $TARGETS + echo "TARGETS=$TARGETS" >> $GITHUB_ENV + else + echo "[LOG] No standalone unit test targets to run." + echo "TARGETS=" >> $GITHUB_ENV + + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO}/commit/${BASE_SHA})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO}/commit/${HEAD_SHA})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "Modified files do not have standalone unit test targets." >> coverage_report.md + + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + fi + + - name: Merge Profiles + if: env.TARGETS != '' + run: | + find . -name "libc_cov_*.profraw" > profraw_list.txt + NUM_PROFS=$(wc -l < profraw_list.txt || echo 0) + echo "[LOG] Discovered $NUM_PROFS raw profile data files." + if [ -s profraw_list.txt ]; then + llvm-profdata-23 merge -sparse --input-files=profraw_list.txt -o libc_full.profdata + echo "[LOG] Successfully merged profile data into libc_full.profdata." + else + echo "[LOG] Warning: No profraw files found." + fi + + - name: Extract Executables and Generate Summary + if: env.TARGETS != '' + run: | + EXECUTABLES=($(find build-cov -type f -executable -name "*__build__")) + OBJECTS=("${EXECUTABLES[@]:1}") + OBJECTS=("${OBJECTS[@]/#/-object=}") + + echo "[LOG] Discovered ${#EXECUTABLES[@]} instrumented binaries for llvm-cov export." + + if [ ! -f libc_full.profdata ] || [ ${#EXECUTABLES[@]} -eq 0 ]; then + echo "[LOG] Notice: Profile data or instrumented executables not found. Generating non-coverage summary." + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO}/commit/${BASE_SHA})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO}/commit/${HEAD_SHA})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "No executable statement profile data was collected for the modified lines in this patch." >> coverage_report.md + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # 1. Generate JSON report for Python script + llvm-cov-23 export -format=text -instr-profile=libc_full.profdata "${EXECUTABLES[0]}" "${OBJECTS[@]}" > coverage.json + + # 2. Generate git diff for modified libc source files + git diff "$DIFF_BASE" HEAD -- libc/src/ > patch.diff + + # 3. Run Diff Coverage Python Script using persisted commit metadata + PYTHONPATH="libc/utils/coverage" python3 libc/utils/coverage/diff_coverage.py patch.diff coverage.json "$BASE_SHA" "$HEAD_SHA" "$BASE_REF" "$HEAD_REF" "$TARGETS" "$BASE_REPO" "$HEAD_REPO" > coverage_report.md + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + echo "[LOG] Coverage report successfully written to GITHUB_STEP_SUMMARY." + + - name: Fallback Failure Summary + if: failure() && !hashFiles('coverage_report.md') + run: | + echo "## LLVM-libc Patch Coverage Report" > coverage_report.md + echo "" >> coverage_report.md + echo "- **Base Branch:** [\`${BASE_REF:-main}\` (${BASE_SHA:0:7})](https://github.com/${BASE_REPO:-llvm/llvm-project}/commit/${BASE_SHA:-main})" >> coverage_report.md + echo "- **Head Commit:** [\`${HEAD_REF:-HEAD}\` (${HEAD_SHA:0:7})](https://github.com/${HEAD_REPO:-llvm/llvm-project}/commit/${HEAD_SHA:-HEAD})" >> coverage_report.md + echo "---" >> coverage_report.md + echo "" >> coverage_report.md + echo "**Coverage Validated**" >> coverage_report.md + echo "The targeted build or test execution encountered an error before coverage data could be finalized. Inspect the job logs for details." >> coverage_report.md + + cat coverage_report.md >> $GITHUB_STEP_SUMMARY + + + - name: Upload Patch MC/DC Coverage Artifacts + if: always() && hashFiles('coverage_report.md') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libc-patch-mcdc-coverage-report + path: | + coverage_report.md + patch.diff + coverage.json + if-no-files-found: ignore + retention-days: 14 + overwrite: true diff --git a/libc/utils/coverage/codebase_coverage.py b/libc/utils/coverage/codebase_coverage.py new file mode 100644 index 0000000000000..03b35e84ace70 --- /dev/null +++ b/libc/utils/coverage/codebase_coverage.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# +# ===- Generate codebase coverage reports --------------------*- python -*--==# +# +# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ==------------------------------------------------------------------------==# + +""" +Standalone file for generating whole-codebase statement, branch, and MC/DC +coverage reports. + +This script parses full-codebase `llvm-cov export` JSON files, aggregates +metrics across all top-level LLVM-libc directories (e.g. `src/ctype`, `src/math`, +`src/string`), and outputs Markdown summary tables for CI step summaries. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +DEFAULT_REPOSITORY = "llvm/llvm-project" + + +@dataclass +class DirectoryCoverageMetrics: + """Encapsulates coverage metrics and boolean decision counts for a directory.""" + + name: str = "" + lines_cov: int = 0 + lines_tot: int = 0 + func_cov: int = 0 + func_tot: int = 0 + mcdc_cov: int = 0 + mcdc_tot: int = 0 + decisions_tot: int = 0 + decisions_full: int = 0 + + @property + def line_pct(self) -> float: + """Percentage of executed statements.""" + return (self.lines_cov / self.lines_tot * 100.0) if self.lines_tot > 0 else 0.0 + + @property + def func_pct(self) -> float: + """Percentage of executed functions.""" + return (self.func_cov / self.func_tot * 100.0) if self.func_tot > 0 else 0.0 + + @property + def mcdc_pct(self) -> float: + """Percentage of evaluated independent boolean conditions.""" + return (self.mcdc_cov / self.mcdc_tot * 100.0) if self.mcdc_tot > 0 else 0.0 + + @property + def decisions_pct(self) -> float: + """Percentage of fully verified boolean decisions.""" + if self.decisions_tot == 0: + return 0.0 + return self.decisions_full / self.decisions_tot * 100.0 + + @property + def missed_lines(self) -> int: + """Count of unexecuted lines.""" + return max(0, self.lines_tot - self.lines_cov) + + +@dataclass +class FullCoverageSummary: + """Encapsulates global and directory-level coverage statistics across LLVM-libc.""" + + global_stats: DirectoryCoverageMetrics = field( + default_factory=DirectoryCoverageMetrics + ) + directories: Dict[str, DirectoryCoverageMetrics] = field(default_factory=dict) + + @property + def has_mcdc(self) -> bool: + """Returns True if any MC/DC condition data exists in the summary.""" + return self.global_stats.mcdc_tot > 0 + + +def extract_full_coverage_statistics( + cov_data: dict, +) -> Optional[FullCoverageSummary]: + """Extracts global and per-directory metrics from llvm-cov export JSON data.""" + if "data" not in cov_data or not cov_data["data"]: + return None + + global_metrics = DirectoryCoverageMetrics(name="global") + directories: Dict[str, DirectoryCoverageMetrics] = {} + + for item in cov_data["data"][0].get("files", []): + file_path = item.get("filename", "") + if "src/" not in file_path or "/test/" in file_path or "/utils/" in file_path: + continue + + idx = file_path.find("src/") + rel_path = file_path[idx:] + + summary = item.get("summary", {}) + lines_summary = summary.get("lines", {}) + func_summary = summary.get("functions", {}) + mcdc_summary = summary.get("mcdc", {}) + + line_tot = lines_summary.get("count", 0) + line_cov = lines_summary.get("covered", 0) + func_tot = func_summary.get("count", 0) + func_cov = func_summary.get("covered", 0) + mcdc_tot = mcdc_summary.get("count", 0) + mcdc_cov = mcdc_summary.get("covered", 0) + + if line_tot == 0: + continue + + mcdc_records = item.get("mcdc_records", []) + valid_mcdc_records = [ + rec + for rec in mcdc_records + if len(rec) >= 10 and isinstance(rec[9], list) and len(rec[9]) > 0 + ] + file_decisions_tot = len(valid_mcdc_records) + file_decisions_full = sum(1 for rec in valid_mcdc_records if all(rec[9])) + + global_metrics.lines_cov += line_cov + global_metrics.lines_tot += line_tot + global_metrics.func_cov += func_cov + global_metrics.func_tot += func_tot + global_metrics.mcdc_cov += mcdc_cov + global_metrics.mcdc_tot += mcdc_tot + global_metrics.decisions_tot += file_decisions_tot + global_metrics.decisions_full += file_decisions_full + + parts = rel_path.split("/") + directory_name = "/".join(parts[:2]) if len(parts) >= 2 else parts[0] + + if directory_name not in directories: + directories[directory_name] = DirectoryCoverageMetrics(name=directory_name) + + dir_metrics = directories[directory_name] + dir_metrics.lines_cov += line_cov + dir_metrics.lines_tot += line_tot + dir_metrics.func_cov += func_cov + dir_metrics.func_tot += func_tot + dir_metrics.mcdc_cov += mcdc_cov + dir_metrics.mcdc_tot += mcdc_tot + dir_metrics.decisions_tot += file_decisions_tot + dir_metrics.decisions_full += file_decisions_full + + if global_metrics.lines_tot == 0: + return None + + return FullCoverageSummary( + global_stats=global_metrics, + directories=directories, + ) + + +def format_overview_callout(summary: FullCoverageSummary) -> str: + """Generates the executive summary banner.""" + g = summary.global_stats + lines: List[str] = [] + + if summary.has_mcdc: + lines.append( + f"### Overall Codebase Coverage: **{g.line_pct:.2f}% Line**" + f" | **{g.mcdc_pct:.2f}% MC/DC**" + ) + lines.append( + f"Tested **{g.lines_cov:,} / {g.lines_tot:,}** executable lines " + f"and **{g.mcdc_cov:,} / {g.mcdc_tot:,}** boolean conditions " + f"across **{g.decisions_tot:,}** decisions." + ) + else: + lines.append(f"### Overall Codebase Coverage: **{g.line_pct:.2f}%**") + lines.append( + f"Tested **{g.lines_cov:,} / {g.lines_tot:,}** executable lines " + "across all LLVM-libc directories." + ) + + lines.append("") + lines.append( + "- **HTML Coverage Report:** Available for download under the " + "**Artifacts** section of this workflow run." + ) + return "\n".join(lines) + + +def format_global_summary_table(summary: FullCoverageSummary) -> str: + """Generates the top-level metric summary table.""" + g = summary.global_stats + lines: List[str] = [ + "### Overall", + "| Metric | Covered | Total | Coverage % |", + "| :--- | :---: | :---: | :---: |", + ] + + if summary.has_mcdc: + lines.append( + f"| **MC/DC Condition Independence** | {g.mcdc_cov:,} | " + f"{g.mcdc_tot:,} | **{g.mcdc_pct:.2f}%** |" + ) + lines.append( + f"| **Fully Verified Decisions** | {g.decisions_full:,} | " + f"{g.decisions_tot:,} | **{g.decisions_pct:.2f}%** |" + ) + + lines.append( + f"| **Executable Lines** | {g.lines_cov:,} | {g.lines_tot:,} | " + f"**{g.line_pct:.2f}%** |" + ) + lines.append( + f"| **Functions** | {g.func_cov:,} | {g.func_tot:,} | " + f"**{g.func_pct:.2f}%** |" + ) + return "\n".join(lines) + + +def format_directory_breakdown_table(summary: FullCoverageSummary) -> str: + """Generates the directory breakdown table.""" + lines: List[str] = ["### Coverage Breakdown"] + has_mcdc = summary.has_mcdc + + if has_mcdc: + lines.append( + "| Directory | MC/DC Conditions | Decisions (Verified / Total) | " + "Line Coverage | Function Coverage | Executable Lines | Missed Lines |" + ) + lines.append("| :--- | :---: | :---: | :---: | :---: | :---: | :---: |") + else: + lines.append( + "| Directory | Line Coverage | Function Coverage | " + "Executable Lines | Missed Lines |" + ) + lines.append("| :--- | :---: | :---: | :---: | :---: |") + + for dir_name in sorted(summary.directories.keys()): + data = summary.directories[dir_name] + if has_mcdc: + mc_cell = ( + f"**{data.mcdc_pct:.1f}%** ({data.mcdc_cov}/{data.mcdc_tot})" + if data.mcdc_tot > 0 + else "N/A" + ) + dec_cell = ( + f"{data.decisions_full} / {data.decisions_tot}" + if data.decisions_tot > 0 + else "N/A" + ) + lines.append( + f"| `libc/{dir_name}` | {mc_cell} | {dec_cell} | " + f"**{data.line_pct:.2f}%** | {data.func_pct:.2f}% | " + f"{data.lines_tot:,} | {data.missed_lines:,} |" + ) + else: + lines.append( + f"| `libc/{dir_name}` | **{data.line_pct:.2f}%** | " + f"{data.func_pct:.2f}% | {data.lines_tot:,} | " + f"{data.missed_lines:,} |" + ) + + return "\n".join(lines) + + +def render_full_report(cov_data: dict) -> None: + """Orchestrates extraction and renders the full Markdown report to stdout.""" + summary = extract_full_coverage_statistics(cov_data) + + print("## LLVM-libc Full Codebase Coverage Report\n") + + if not summary: + print("### No Coverage Data Detected") + print("The test execution completed but no coverage profiles were exported.") + return + + # 1. Executive Callout Banner + print(format_overview_callout(summary)) + print("\n---\n") + + # 2. Global Metric Summary Table + print(format_global_summary_table(summary)) + print("") + + # 3. Directory Breakdown Table + print(format_directory_breakdown_table(summary)) + + +def main() -> None: + """Parses command-line arguments and triggers report generation.""" + parser = argparse.ArgumentParser(description="LLVM-libc Codebase Coverage Analyzer") + parser.add_argument("json_file", help="Path to llvm-cov export JSON file") + parser.add_argument( + "commit_sha", + nargs="?", + default="", + help="Commit SHA under evaluation", + ) + parser.add_argument( + "branch_ref", + nargs="?", + default="", + help="Branch reference under evaluation", + ) + + args, _ = parser.parse_known_args() + + try: + with open(args.json_file, "r", encoding="utf-8") as f: + cov_data = json.load(f) + except Exception as err: + sys.stderr.write( + f"Error: Failed to parse coverage JSON from '{args.json_file}': {err}\n" + ) + sys.exit(1) + + render_full_report(cov_data) + + +if __name__ == "__main__": + main() diff --git a/libc/utils/coverage/diff_coverage.py b/libc/utils/coverage/diff_coverage.py new file mode 100644 index 0000000000000..8bfbe78e1cda5 --- /dev/null +++ b/libc/utils/coverage/diff_coverage.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 +# +# ===- Generate diff coverage reports ------------------------*- python -*--==# +# +# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ==------------------------------------------------------------------------==# + +""" +Standalone analyzer for evaluating diff-level statement, branch, and MC/DC +coverage. + +This script parses unified git diffs alongside `llvm-cov export` JSON summaries, +correlates added/modified lines with execution counts and boolean decision +records, and outputs formatted Markdown reports for CI job summaries and PR +comments. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set, Tuple + +DEFAULT_BASE_REPOSITORY = "llvm/llvm-project" +DEFAULT_HEAD_REPOSITORY = "llvm/llvm-project" + +COMMENT_PREFIXES = ("//", "/*", "*", "*/") +STRUCTURAL_TOKENS = ("{", "}", "};", "{};") +DECLARATION_PREFIXES = ( + "namespace ", + "extern ", + "using ", + "__attribute__", + "template", + "typedef ", + "friend ", + "static_assert", +) +ACCESS_SPECIFIERS = ("public:", "private:", "protected:") + + +@dataclass +class DiffHunk: + """Represents a unified diff hunk with its header and line tokens.""" + + header: str + lines: List[Tuple[str, str, int]] = field( + default_factory=list + ) # (prefix, text, line_number) + + +@dataclass +class FilePatchMetrics: + """Encapsulates coverage metrics and decisions for a single modified file.""" + + file_path: str + covered_lines: Set[int] = field(default_factory=set) + missed_lines: Set[int] = field(default_factory=set) + added_lines: Set[int] = field(default_factory=set) + mcdc_covered_conditions: int = 0 + mcdc_total_conditions: int = 0 + decisions_verified: int = 0 + decisions_total: int = 0 + condition_diagnostics: List[str] = field(default_factory=list) + unverified_decision_lines: Dict[int, List[str]] = field(default_factory=dict) + + @property + def total_lines(self) -> int: + """Total executable lines evaluated in this file.""" + return len(self.covered_lines) + len(self.missed_lines) + + @property + def line_coverage_percentage(self) -> float: + """Percentage of executed patch lines.""" + return ( + (len(self.covered_lines) / self.total_lines * 100.0) + if self.total_lines > 0 + else 0.0 + ) + + @property + def mcdc_coverage_percentage(self) -> float: + """Percentage of independent boolean conditions evaluated.""" + return ( + (self.mcdc_covered_conditions / self.mcdc_total_conditions * 100.0) + if self.mcdc_total_conditions > 0 + else 0.0 + ) + + +@dataclass +class PatchCoverageSummary: + """Aggregated coverage statistics across all modified files in the patch.""" + + files: Dict[str, FilePatchMetrics] = field(default_factory=dict) + total_covered_lines: int = 0 + total_missed_lines: int = 0 + total_mcdc_covered_conditions: int = 0 + total_mcdc_total_conditions: int = 0 + total_decisions_count: int = 0 + fully_verified_decisions: int = 0 + + @property + def total_lines(self) -> int: + """Total executable lines across all modified files in the patch.""" + return self.total_covered_lines + self.total_missed_lines + + @property + def line_coverage_percentage(self) -> float: + """Aggregated patch line coverage percentage.""" + return ( + (self.total_covered_lines / self.total_lines * 100.0) + if self.total_lines > 0 + else 0.0 + ) + + @property + def mcdc_coverage_percentage(self) -> float: + """Aggregated patch MC/DC condition coverage percentage.""" + if self.total_mcdc_total_conditions == 0: + return 0.0 + return ( + self.total_mcdc_covered_conditions + / self.total_mcdc_total_conditions + * 100.0 + ) + + @property + def has_mcdc(self) -> bool: + """Returns True if any MC/DC decision records intersect the patch.""" + return self.total_mcdc_total_conditions > 0 + + +class DiffParser: + """Parses unified diff outputs into structured file hunks with line numbers.""" + + @staticmethod + def parse(diff_source: str) -> Dict[str, List[DiffHunk]]: + """Parses a diff file into a mapping of file path to hunks.""" + files: Dict[str, List[DiffHunk]] = {} + current_file: Optional[str] = None + current_hunk: Optional[DiffHunk] = None + current_line_number: int = 0 + + if os.path.isfile(diff_source): + with open( + diff_source, "r", encoding="utf-8", errors="replace" + ) as file_handle: + lines = file_handle.readlines() + else: + lines = diff_source.splitlines(keepends=True) + + for raw_line in lines: + line = raw_line.rstrip("\n") + + if line.startswith("+++ b/"): + current_file = line[6:] + files[current_file] = [] + current_hunk = None + continue + + if line.startswith("+++ /dev/null"): + current_file = None + current_hunk = None + continue + + if current_file is None: + continue + + if line.startswith("@@"): + hunk_match = re.search(r"\+([0-9]+)", line) + if hunk_match: + current_line_number = int(hunk_match.group(1)) + current_hunk = DiffHunk(header=line) + files[current_file].append(current_hunk) + continue + + if current_hunk is None: + continue + + if line.startswith("-"): + continue + elif line.startswith("+"): + current_hunk.lines.append(("+", line[1:], current_line_number)) + current_line_number += 1 + elif line.startswith(" "): + current_hunk.lines.append((" ", line[1:], current_line_number)) + current_line_number += 1 + + return files + + +class CoverageJSONParser: + """Parses statement segments and MC/DC records from llvm-cov JSON export.""" + + @staticmethod + def load(json_path: str) -> dict: + """Loads JSON file from disk with error reporting.""" + try: + with open(json_path, "r", encoding="utf-8") as file_handle: + return json.load(file_handle) + except Exception as error: + sys.stderr.write( + f"Error: Failed to parse coverage JSON from '{json_path}': {error}\n" + ) + sys.exit(1) + + @staticmethod + def extract_patch_matrix( + coverage_data: dict, diff_files: Dict[str, List[DiffHunk]] + ) -> Dict[str, Dict[str, Any]]: + """Maps coverage segments and MC/DC decision records to modified files.""" + coverage_matrix: Dict[str, Dict[str, Any]] = { + file_path: { + "covered": set(), + "missed": set(), + "mcdc_decisions": [], + } + for file_path in diff_files.keys() + } + + if "data" not in coverage_data or not coverage_data["data"]: + return coverage_matrix + + for item in coverage_data["data"][0].get("files", []): + file_name = item.get("filename", "") + relative_file_path = next( + ( + target_path + for target_path in diff_files.keys() + if file_name == target_path + or file_name.endswith("/" + target_path) + or target_path.endswith("/" + file_name) + ), + None, + ) + if not relative_file_path: + continue + + # 1. Process statement coverage segments + segments = item.get("segments", []) + for index, current_segment in enumerate(segments): + line_start = current_segment[0] + execution_count = current_segment[2] + has_execution_count = current_segment[3] + + if not has_execution_count: + continue + + if index < len(segments) - 1: + next_segment = segments[index + 1] + next_line = next_segment[0] + end_range = next_line if next_line > line_start else line_start + 1 + else: + end_range = line_start + 1 + + for line_number in range(line_start, end_range): + if execution_count > 0: + coverage_matrix[relative_file_path]["covered"].add(line_number) + else: + coverage_matrix[relative_file_path]["missed"].add(line_number) + + # 2. Process MC/DC decision records + mcdc_records = item.get("mcdc_records", []) + for record in mcdc_records: + if ( + len(record) >= 10 + and isinstance(record[9], list) + and len(record[9]) > 0 + ): + decision_start_line = record[0] + decision_end_line = record[2] + boolean_conditions = record[9] + covered_conditions_count = sum( + 1 for condition in boolean_conditions if condition + ) + coverage_matrix[relative_file_path]["mcdc_decisions"].append( + { + "line_start": decision_start_line, + "line_end": decision_end_line, + "conditions": boolean_conditions, + "covered": covered_conditions_count, + "total": len(boolean_conditions), + } + ) + + return coverage_matrix + + +def is_executable_line(line_text: str) -> bool: + """Filters out non-executable code lines (comments, braces, pure declarations).""" + stripped_line = line_text.strip() + if not stripped_line: + return False + # Strip block comments on the same line and trailing line comments + clean_line = re.sub(r"/\*.*?\*/", "", stripped_line) + clean_line = re.sub(r"//.*$", "", clean_line).strip() + if not clean_line: + return False + if ( + clean_line.startswith("*") + or clean_line.startswith("/*") + or clean_line.startswith("*/") + ): + return False + if clean_line in STRUCTURAL_TOKENS or clean_line.startswith(":"): + return False + if clean_line in ACCESS_SPECIFIERS: + return False + if clean_line.startswith("#"): + return False + if any(clean_line.startswith(prefix) for prefix in DECLARATION_PREFIXES): + return False + if ( + clean_line.startswith("struct ") + or clean_line.startswith("class ") + or clean_line.startswith("enum ") + ): + if ("{" in clean_line and "=" not in clean_line) or ( + clean_line.endswith(";") and "=" not in clean_line and "(" not in clean_line + ): + return False + return True + + +def format_line_ranges(line_numbers: Set[int]) -> str: + """Formats an integer set of line numbers into concise span representations.""" + if not line_numbers: + return "None" + sorted_line_numbers = sorted(line_numbers) + formatted_ranges: List[str] = [] + start_line = sorted_line_numbers[0] + end_line = sorted_line_numbers[0] + for current_number in sorted_line_numbers[1:]: + if current_number == end_line + 1: + end_line = current_number + else: + range_label = ( + f"`L{start_line}-L{end_line}`" + if start_line != end_line + else f"`L{start_line}`" + ) + formatted_ranges.append(range_label) + start_line = end_line = current_number + range_label = ( + f"`L{start_line}-L{end_line}`" if start_line != end_line else f"`L{start_line}`" + ) + formatted_ranges.append(range_label) + return ", ".join(formatted_ranges) + + +def calculate_patch_statistics( + diff_files: Dict[str, List[DiffHunk]], + coverage_matrix: Dict[str, Dict[str, Any]], +) -> PatchCoverageSummary: + """Calculates line, branch, and MC/DC statistics for modified patch files.""" + summary = PatchCoverageSummary() + + for file_path, file_data in coverage_matrix.items(): + if ( + not any(file_path.endswith(ext) for ext in (".cpp", ".c", ".h", ".inc")) + or "/test/" in file_path + or file_path.startswith("test/") + or "/utils/" in file_path + or file_path.startswith("utils/") + ): + continue + + added_lines: Set[int] = set() + for hunk in diff_files.get(file_path, []): + for line_type, text, line_number in hunk.lines: + if line_type == "+" and is_executable_line(text): + added_lines.add(line_number) + + if not added_lines: + continue + + file_covered_lines = added_lines.intersection(file_data["covered"]) + file_missed_lines = ( + added_lines.intersection(file_data["missed"]) + ) - file_covered_lines + + file_metrics = FilePatchMetrics( + file_path=file_path, + added_lines=added_lines, + ) + + if len(file_data["covered"]) > 0 or len(file_data["missed"]) > 0: + file_metrics.covered_lines = file_covered_lines + file_metrics.missed_lines = file_missed_lines + summary.total_covered_lines += len(file_covered_lines) + summary.total_missed_lines += len(file_missed_lines) + else: + file_metrics.missed_lines = added_lines + summary.total_missed_lines += len(added_lines) + + # Evaluate MC/DC decision records intersecting modified lines + for decision in file_data.get("mcdc_decisions", []): + decision_start_line = decision["line_start"] + decision_end_line = decision["line_end"] + if any( + decision_start_line <= line_number <= decision_end_line + for line_number in added_lines + ): + summary.total_decisions_count += 1 + file_metrics.decisions_total += 1 + file_metrics.mcdc_covered_conditions += decision["covered"] + file_metrics.mcdc_total_conditions += decision["total"] + summary.total_mcdc_covered_conditions += decision["covered"] + summary.total_mcdc_total_conditions += decision["total"] + + if decision["covered"] == decision["total"]: + summary.fully_verified_decisions += 1 + file_metrics.decisions_verified += 1 + file_metrics.condition_diagnostics.append( + f"`L{decision_start_line}`: " + f"{decision['covered']}/{decision['total']} verified" + ) + else: + uncovered_indices = [ + f"C{condition_index + 1}" + for condition_index, is_covered in enumerate( + decision["conditions"] + ) + if not is_covered + ] + unverified_conditions_string = ", ".join(uncovered_indices) + file_metrics.condition_diagnostics.append( + f"`L{decision_start_line}`: " + f"{decision['covered']}/{decision['total']} verified " + f"({unverified_conditions_string} unverified)" + ) + for decision_line in range( + decision_start_line, decision_end_line + 1 + ): + if decision_line in added_lines: + file_metrics.unverified_decision_lines[ + decision_line + ] = uncovered_indices + + summary.files[file_path] = file_metrics + + return summary + + +def format_status_banner(summary: PatchCoverageSummary) -> str: + """Generates the executive summary block.""" + lines: List[str] = [] + if summary.total_missed_lines == 0: + if not summary.has_mcdc: + lines.append( + f"### Patch Coverage: **{summary.line_coverage_percentage:.2f}%**" + ) + lines.append( + f"All **{summary.total_lines}** newly added or modified " + "executable lines are covered." + ) + elif ( + summary.total_mcdc_covered_conditions == summary.total_mcdc_total_conditions + ): + lines.append( + f"### Patch Coverage: " + f"**{summary.line_coverage_percentage:.2f}% Line** | " + "**100.00% MC/DC**" + ) + lines.append( + f"All **{summary.total_lines}** executable lines and " + f"**{summary.total_mcdc_total_conditions}** boolean conditions " + f"across **{summary.total_decisions_count}** decisions are covered." + ) + else: + lines.append( + f"### Patch Coverage: " + f"**{summary.line_coverage_percentage:.2f}% Line** | " + f"**{summary.mcdc_coverage_percentage:.1f}% MC/DC**" + ) + lines.append( + f"Executed **{summary.total_covered_lines} / {summary.total_lines}** " + f"lines. **{summary.total_mcdc_covered_conditions} / " + f"{summary.total_mcdc_total_conditions}** boolean conditions " + f"achieved independence across **{summary.fully_verified_decisions} / " + f"{summary.total_decisions_count}** decisions." + ) + else: + if not summary.has_mcdc: + lines.append( + f"### Patch Coverage: " + f"**{summary.line_coverage_percentage:.2f}%** " + f"({summary.total_missed_lines} Missed Lines)" + ) + lines.append( + f"Executed **{summary.total_covered_lines} / {summary.total_lines}** " + f"lines (**{summary.total_missed_lines}** unexecuted lines " + "detected in patch)." + ) + else: + lines.append( + f"### Patch Coverage: " + f"**{summary.line_coverage_percentage:.2f}% Line** | " + f"**{summary.mcdc_coverage_percentage:.1f}% MC/DC** " + f"({summary.total_missed_lines} Missed Lines)" + ) + lines.append( + f"Executed **{summary.total_covered_lines} / {summary.total_lines}** " + f"lines. **{summary.total_mcdc_covered_conditions} / " + f"{summary.total_mcdc_total_conditions}** boolean conditions " + f"achieved independence across **{summary.fully_verified_decisions} / " + f"{summary.total_decisions_count}** decisions " + f"(**{summary.total_missed_lines}** unexecuted lines " + "detected in patch)." + ) + return "\n".join(lines) + + +def format_metadata_section( + base_commit_sha: Optional[str], + head_commit_sha: Optional[str], + base_branch_name: Optional[str], + head_branch_name: Optional[str], + targeted_tests_string: Optional[str] = None, + base_repository: str = DEFAULT_BASE_REPOSITORY, + head_repository: str = DEFAULT_HEAD_REPOSITORY, +) -> str: + """Formats Git commit and target test metadata.""" + lines: List[str] = [] + if base_commit_sha and head_commit_sha and base_branch_name and head_branch_name: + lines.append( + f"- **Base Branch:** [`{base_branch_name}` ({base_commit_sha[:7]})]" + f"(https://github.com/{base_repository}/commit/{base_commit_sha})" + ) + lines.append( + f"- **Head Commit:** [`{head_branch_name}` ({head_commit_sha[:7]})]" + f"(https://github.com/{head_repository}/commit/{head_commit_sha})" + ) + if targeted_tests_string: + formatted_targets = ", ".join( + f"`{target.strip()}`" + for target in targeted_tests_string.split() + if target.strip() + ) + lines.append(f"- **Targeted Tests Executed:** {formatted_targets}") + return "\n".join(lines) + + +def format_breakdown_table( + summary: PatchCoverageSummary, + head_repository: str = DEFAULT_HEAD_REPOSITORY, + head_commit_sha: Optional[str] = None, +) -> str: + """Generates the Markdown table breaking down coverage per source file.""" + lines: List[str] = ["### Coverage Breakdown"] + if summary.has_mcdc: + lines.append( + "| Modified Source File | Line Coverage | MC/DC Conditions | " + "Decisions (Verified / Total) | Missed Lines | Unverified Conditions |" + ) + lines.append("| :--- | :---: | :---: | :---: | :---: | :--- |") + else: + lines.append( + "| Modified Source File | Patch Coverage | Covered / Total | " + "Missed Lines | Unexecuted Line Spans |" + ) + lines.append("| :--- | :---: | :---: | :---: | :---: |") + + for file_path, file_metric in summary.files.items(): + commit_ref = head_commit_sha or "main" + repo_file_path = ( + file_path if file_path.startswith("libc/") else f"libc/{file_path}" + ) + file_link = ( + f"[`{file_path}`]" + f"(https://github.com/{head_repository}/blob/{commit_ref}/{repo_file_path})" + ) + missed_lines = file_metric.missed_lines + covered_count = len(file_metric.covered_lines) + total_file_lines = file_metric.total_lines + + if summary.has_mcdc: + mc_pct = file_metric.mcdc_coverage_percentage + mc_cov = file_metric.mcdc_covered_conditions + mc_tot = file_metric.mcdc_total_conditions + mcdc_cell = ( + f"**{mc_pct:.1f}%** ({mc_cov}/{mc_tot})" if mc_tot > 0 else "N/A" + ) + dec_cell = ( + f"**{file_metric.decisions_verified} / " + f"{file_metric.decisions_total}**" + if file_metric.decisions_total > 0 + else "N/A" + ) + diagnostic_cell = ( + "
".join(file_metric.condition_diagnostics) + if file_metric.condition_diagnostics + else "None" + ) + lines.append( + f"| {file_link} | " + f"**{file_metric.line_coverage_percentage:.2f}%** " + f"({covered_count}/{total_file_lines}) | " + f"{mcdc_cell} | {dec_cell} | " + f"{len(missed_lines)} | {diagnostic_cell} |" + ) + else: + line_spans = format_line_ranges(missed_lines) + lines.append( + f"| {file_link} | " + f"**{file_metric.line_coverage_percentage:.2f}%** | " + f"{covered_count} / {total_file_lines} | " + f"{len(missed_lines)} | {line_spans} |" + ) + + # Summary Row + if summary.has_mcdc: + total_decision_cell = ( + f"**{summary.fully_verified_decisions} / " + f"{summary.total_decisions_count}**" + ) + lines.append( + f"| **Total (Patch)** | " + f"**{summary.line_coverage_percentage:.2f}%** " + f"({summary.total_covered_lines}/{summary.total_lines}) | " + f"**{summary.mcdc_coverage_percentage:.1f}%** " + f"({summary.total_mcdc_covered_conditions}/" + f"{summary.total_mcdc_total_conditions}) | " + f"{total_decision_cell} | **{summary.total_missed_lines}** | - |" + ) + else: + lines.append( + f"| **Total (Patch)** | " + f"**{summary.line_coverage_percentage:.2f}%** | " + f"{summary.total_covered_lines} / {summary.total_lines} | " + f"**{summary.total_missed_lines}** | - |" + ) + + return "\n".join(lines) + + +def format_annotated_diff( + summary: PatchCoverageSummary, + diff_files: Dict[str, List[DiffHunk]], +) -> str: + """Renders the collapsible source map diff with execution indicators.""" + lines: List[str] = [ + "
", + "View Annotated Patch Diff (Source Map)\n", + ] + + for file_path, file_metric in summary.files.items(): + hunks = diff_files.get(file_path, []) + unverified_decision_lines = file_metric.unverified_decision_lines + + lines.append(f"#### `{file_path}`") + lines.append("```diff") + for hunk in hunks: + lines.append(hunk.header) + for line_type, line_text, line_number in hunk.lines: + if line_type == "+": + if line_number in file_metric.missed_lines: + lines.append(f"- {line_text} // [MISSED]") + elif line_number in unverified_decision_lines: + unverified_conditions = ", ".join( + unverified_decision_lines[line_number] + ) + lines.append( + f"! {line_text} // [PARTIAL MC/DC: " + f"{unverified_conditions} unverified]" + ) + elif line_number in file_metric.covered_lines: + lines.append(f"+ {line_text}") + else: + lines.append(f" {line_text}") + elif line_type == " ": + lines.append(f" {line_text}") + lines.append("```\n") + + lines.append("
") + return "\n".join(lines) + + +def render_patch_report( + diff_files: Dict[str, List[DiffHunk]], + coverage_matrix: Dict[str, Dict[str, Any]], + base_commit_sha: Optional[str], + head_commit_sha: Optional[str], + base_branch_name: Optional[str], + head_branch_name: Optional[str], + targeted_tests_string: Optional[str] = None, + base_repository: str = DEFAULT_BASE_REPOSITORY, + head_repository: str = DEFAULT_HEAD_REPOSITORY, +) -> None: + """Composes and outputs the full Markdown report.""" + summary = calculate_patch_statistics(diff_files, coverage_matrix) + + if summary.has_mcdc: + print("## LLVM-libc MC/DC Patch Coverage Report\n") + else: + print("## LLVM-libc Patch Coverage Report\n") + + if summary.total_lines == 0 or not summary.files: + metadata_section_string = format_metadata_section( + base_commit_sha, + head_commit_sha, + base_branch_name, + head_branch_name, + targeted_tests_string, + base_repository, + head_repository, + ) + if metadata_section_string: + print(metadata_section_string) + print("\n---\n") + print("### Coverage Summary") + print("No executable lines were added or modified in this patch.") + return + + # 1. Status Banner + print(format_status_banner(summary)) + print("") + + # 2. Metadata Section + metadata_section_string = format_metadata_section( + base_commit_sha, + head_commit_sha, + base_branch_name, + head_branch_name, + targeted_tests_string, + base_repository, + head_repository, + ) + if metadata_section_string: + print(metadata_section_string) + print("\n---\n") + + # 3. Breakdown Table + print(format_breakdown_table(summary, head_repository, head_commit_sha)) + print("") + + # 4. Source Map Diff + print(format_annotated_diff(summary, diff_files)) + + +def main() -> None: + """Parses command-line arguments and triggers report generation.""" + parser = argparse.ArgumentParser(description="LLVM-libc Diff Coverage Analyzer") + parser.add_argument("diff_file", help="Path to unified diff file") + parser.add_argument("json_file", help="Path to llvm-cov export JSON file") + parser.add_argument("base_sha", nargs="?", help="Base commit SHA") + parser.add_argument("head_sha", nargs="?", help="Head commit SHA") + parser.add_argument("base_branch", nargs="?", help="Base branch name") + parser.add_argument("head_branch", nargs="?", help="Head branch name") + parser.add_argument( + "targets", + nargs="?", + help="Space-separated list of executed test targets", + ) + parser.add_argument( + "base_repo", + nargs="?", + default=DEFAULT_BASE_REPOSITORY, + help=f"Base repository (default: {DEFAULT_BASE_REPOSITORY})", + ) + parser.add_argument( + "head_repo", + nargs="?", + default=DEFAULT_HEAD_REPOSITORY, + help=f"Head repository (default: {DEFAULT_HEAD_REPOSITORY})", + ) + + arguments = parser.parse_args() + + if not os.path.isfile(arguments.diff_file): + sys.stderr.write(f"Error: Diff file not found: '{arguments.diff_file}'\n") + sys.exit(1) + + diff_files = DiffParser.parse(arguments.diff_file) + coverage_data = CoverageJSONParser.load(arguments.json_file) + coverage_matrix = CoverageJSONParser.extract_patch_matrix(coverage_data, diff_files) + + render_patch_report( + diff_files, + coverage_matrix, + arguments.base_sha, + arguments.head_sha, + arguments.base_branch, + arguments.head_branch, + arguments.targets, + arguments.base_repo, + arguments.head_repo, + ) + + +if __name__ == "__main__": + main() diff --git a/libc/utils/coverage/test_codebase_coverage.py b/libc/utils/coverage/test_codebase_coverage.py new file mode 100644 index 0000000000000..9d9d19ee3fad0 --- /dev/null +++ b/libc/utils/coverage/test_codebase_coverage.py @@ -0,0 +1,705 @@ +# ====- Unit tests for codebase_coverage.py ------------------*- python -*--==# +# +# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ==-------------------------------------------------------------------------==# + +"""Unit tests for codebase_coverage.py.""" + +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +# Ensure libc/utils/coverage is in sys.path when running from any working directory +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from codebase_coverage import ( + DirectoryCoverageMetrics, + FullCoverageSummary, + extract_full_coverage_statistics, + format_directory_breakdown_table, + format_global_summary_table, + format_overview_callout, + main, + render_full_report, +) + + +class TestDirectoryCoverageMetrics(unittest.TestCase): + """Tests DirectoryCoverageMetrics mathematical operations and property safeguards.""" + + def test_zero_totals_return_zero_percentages(self): + """Zero totals must safely evaluate to 0.0 without ZeroDivisionError.""" + metrics = DirectoryCoverageMetrics(name="empty") + self.assertEqual(metrics.line_pct, 0.0) + self.assertEqual(metrics.func_pct, 0.0) + self.assertEqual(metrics.mcdc_pct, 0.0) + self.assertEqual(metrics.decisions_pct, 0.0) + self.assertEqual(metrics.missed_lines, 0) + + def test_percentage_calculations(self): + """Percentages must correctly compute ratios across lines, functions, and MC/DC.""" + metrics = DirectoryCoverageMetrics( + name="src/math", + lines_cov=75, + lines_tot=100, + func_cov=3, + func_tot=4, + mcdc_cov=7, + mcdc_tot=10, + decisions_tot=5, + decisions_full=4, + ) + self.assertAlmostEqual(metrics.line_pct, 75.0, places=2) + self.assertAlmostEqual(metrics.func_pct, 75.0, places=2) + self.assertAlmostEqual(metrics.mcdc_pct, 70.0, places=2) + self.assertAlmostEqual(metrics.decisions_pct, 80.0, places=2) + self.assertEqual(metrics.missed_lines, 25) + + def test_missed_lines_clamping(self): + """Missed lines must clamp to 0 if covered lines exceed total lines.""" + metrics = DirectoryCoverageMetrics( + name="src/clamped", lines_cov=120, lines_tot=100 + ) + self.assertEqual(metrics.missed_lines, 0) + + def test_all_zero_metrics(self): + """Default initialized metrics object must have zero values and empty name.""" + metrics = DirectoryCoverageMetrics() + self.assertEqual(metrics.name, "") + self.assertEqual(metrics.lines_cov, 0) + self.assertEqual(metrics.lines_tot, 0) + self.assertEqual(metrics.decisions_pct, 0.0) + + +class TestFullCoverageSummary(unittest.TestCase): + """Tests FullCoverageSummary attributes and condition indicators.""" + + def test_has_mcdc_property(self): + """has_mcdc must reflect whether global MC/DC conditions exist.""" + without_mcdc = FullCoverageSummary( + global_stats=DirectoryCoverageMetrics(mcdc_tot=0) + ) + self.assertFalse(without_mcdc.has_mcdc) + + with_mcdc = FullCoverageSummary( + global_stats=DirectoryCoverageMetrics(mcdc_tot=12) + ) + self.assertTrue(with_mcdc.has_mcdc) + + def test_default_initialization(self): + """Default FullCoverageSummary must initialize empty directories dictionary.""" + summary = FullCoverageSummary() + self.assertFalse(summary.has_mcdc) + self.assertEqual(len(summary.directories), 0) + + +class TestExtractFullCoverageStatistics(unittest.TestCase): + """Tests JSON extraction, file path filtering, directory grouping, and MC/DC aggregation.""" + + def test_empty_or_invalid_payloads_return_none(self): + """Missing or malformed payload structures must return None.""" + test_cases = [ + ({}, "Empty dictionary"), + ({"data": []}, "Empty data array"), + ({"data": [{}]}, "Data array without files key"), + ({"data": [{"files": []}]}, "Empty files array"), + ] + for payload, description in test_cases: + with self.subTest(msg=description): + self.assertIsNone(extract_full_coverage_statistics(payload)) + + def test_all_files_zero_lines_returns_none(self): + """Payload containing only files with zero total lines must return None.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/empty.cpp", + "summary": { + "lines": {"count": 0, "covered": 0}, + "functions": {"count": 0, "covered": 0}, + }, + } + ] + } + ] + } + self.assertIsNone(extract_full_coverage_statistics(payload)) + + def test_all_files_excluded_returns_none(self): + """Payload containing only test or utility files must return None.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/test/src/math/sin_test.cpp", + "summary": { + "lines": {"count": 100, "covered": 100}, + "functions": {"count": 1, "covered": 1}, + }, + }, + { + "filename": "/workspace/libc/utils/MPFRWrapper/MPFRUtils.cpp", + "summary": { + "lines": {"count": 200, "covered": 200}, + "functions": {"count": 2, "covered": 2}, + }, + }, + ] + } + ] + } + self.assertIsNone(extract_full_coverage_statistics(payload)) + + def test_file_path_filtering(self): + """Test and utility directories must be excluded from codebase coverage.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/math/sin.cpp", + "summary": { + "lines": {"count": 100, "covered": 80}, + "functions": {"count": 2, "covered": 2}, + }, + }, + { + # Test file: must be excluded + "filename": "/workspace/libc/test/src/math/sin_test.cpp", + "summary": { + "lines": {"count": 500, "covered": 500}, + "functions": {"count": 5, "covered": 5}, + }, + }, + { + # Utility file: must be excluded + "filename": "/workspace/libc/utils/MPFRWrapper/MPFRUtils.cpp", + "summary": { + "lines": {"count": 300, "covered": 300}, + "functions": {"count": 4, "covered": 4}, + }, + }, + { + # Non-src file: must be excluded + "filename": "/workspace/libc/include/llvm-libc-types/size_t.h", + "summary": { + "lines": {"count": 50, "covered": 50}, + "functions": {"count": 1, "covered": 1}, + }, + }, + { + # File with zero total lines: must be excluded + "filename": "/workspace/libc/src/empty.cpp", + "summary": { + "lines": {"count": 0, "covered": 0}, + "functions": {"count": 0, "covered": 0}, + }, + }, + ] + } + ] + } + + summary = extract_full_coverage_statistics(payload) + self.assertIsNotNone(summary) + self.assertEqual(summary.global_stats.lines_tot, 100) + self.assertEqual(summary.global_stats.lines_cov, 80) + self.assertEqual(summary.global_stats.func_tot, 2) + self.assertEqual(summary.global_stats.func_cov, 2) + self.assertIn("src/math", summary.directories) + self.assertEqual(len(summary.directories), 1) + + def test_directory_bucketing_and_nested_paths(self): + """Files within identical top-level directories or deep subpaths must aggregate properly.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/math/sin.cpp", + "summary": { + "lines": {"count": 100, "covered": 60}, + "functions": {"count": 2, "covered": 1}, + }, + }, + { + "filename": "/workspace/libc/src/math/cos.cpp", + "summary": { + "lines": {"count": 80, "covered": 80}, + "functions": {"count": 2, "covered": 2}, + }, + }, + { + # Nested subpath under src/string/ + "filename": "/workspace/libc/src/string/memory_utils/op_builtin.cpp", + "summary": { + "lines": {"count": 120, "covered": 100}, + "functions": {"count": 4, "covered": 3}, + }, + }, + ] + } + ] + } + + summary = extract_full_coverage_statistics(payload) + self.assertIsNotNone(summary) + self.assertEqual(summary.global_stats.lines_tot, 300) + self.assertEqual(summary.global_stats.lines_cov, 240) + + self.assertIn("src/math", summary.directories) + math_dir = summary.directories["src/math"] + self.assertEqual(math_dir.lines_tot, 180) + self.assertEqual(math_dir.lines_cov, 140) + self.assertEqual(math_dir.func_tot, 4) + self.assertEqual(math_dir.func_cov, 3) + + self.assertIn("src/string", summary.directories) + str_dir = summary.directories["src/string"] + self.assertEqual(str_dir.lines_tot, 120) + self.assertEqual(str_dir.lines_cov, 100) + + def test_mcdc_records_aggregation_and_decision_tracking(self): + """MC/DC records must be parsed for total conditions and full decision verification.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/math/fma.cpp", + "summary": { + "lines": {"count": 50, "covered": 50}, + "functions": {"count": 1, "covered": 1}, + "mcdc": {"count": 4, "covered": 3}, + }, + "mcdc_records": [ + # Fully verified decision: [True, True] + [10, 5, 10, 20, 0, 0, 0, 0, 0, [True, True]], + # Partially verified decision: [True, False] + [25, 5, 25, 25, 0, 0, 0, 0, 0, [True, False]], + # Malformed record: ignored + [30, 5, 30, 20], + ], + } + ] + } + ] + } + + summary = extract_full_coverage_statistics(payload) + self.assertIsNotNone(summary) + self.assertTrue(summary.has_mcdc) + self.assertEqual(summary.global_stats.mcdc_tot, 4) + self.assertEqual(summary.global_stats.mcdc_cov, 3) + self.assertEqual(summary.global_stats.decisions_tot, 2) + self.assertEqual(summary.global_stats.decisions_full, 1) + + def test_malformed_and_empty_mcdc_records_ignored(self): + """Malformed, non-list, or empty condition vectors must not count as valid decisions.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/math/exp.cpp", + "summary": { + "lines": {"count": 50, "covered": 50}, + "functions": {"count": 1, "covered": 1}, + "mcdc": {"count": 2, "covered": 2}, + }, + "mcdc_records": [ + # Valid fully verified decision + [10, 5, 10, 20, 0, 0, 0, 0, 0, [True]], + # Empty list: not a valid decision + [20, 5, 20, 20, 0, 0, 0, 0, 0, []], + # Non-list 10th element: not a valid decision + [30, 5, 30, 20, 0, 0, 0, 0, 0, None], + # Record length < 10: not a valid decision + [40, 5, 40, 20], + ], + } + ] + } + ] + } + + summary = extract_full_coverage_statistics(payload) + self.assertIsNotNone(summary) + self.assertEqual(summary.global_stats.decisions_tot, 1) + self.assertEqual(summary.global_stats.decisions_full, 1) + + +class TestCodebaseReportFormatting(unittest.TestCase): + """Tests Markdown rendering of callouts, summary tables, and directory breakdowns.""" + + def test_format_overview_callout_without_mcdc(self): + """Callout without MC/DC must render line coverage and artifacts instructions.""" + metrics = DirectoryCoverageMetrics(name="global", lines_tot=1000, lines_cov=850) + summary = FullCoverageSummary(global_stats=metrics) + callout = format_overview_callout(summary) + + self.assertIn("### Overall Codebase Coverage: **85.00%**", callout) + self.assertIn("Tested **850 / 1,000** executable lines", callout) + self.assertIn("Artifacts", callout) + self.assertIn("HTML Coverage Report", callout) + self.assertNotIn("MC/DC", callout) + + def test_format_overview_callout_with_mcdc(self): + """Callout with MC/DC must render both line and condition coverage metrics.""" + metrics = DirectoryCoverageMetrics( + name="global", + lines_tot=2000, + lines_cov=1800, + mcdc_tot=50, + mcdc_cov=45, + decisions_tot=20, + decisions_full=18, + ) + summary = FullCoverageSummary(global_stats=metrics) + callout = format_overview_callout(summary) + + self.assertIn( + "### Overall Codebase Coverage: **90.00% Line** | **90.00% MC/DC**", + callout, + ) + self.assertIn("Tested **1,800 / 2,000** executable lines", callout) + self.assertIn("and **45 / 50** boolean conditions", callout) + self.assertIn("across **20** decisions.", callout) + self.assertIn("Artifacts", callout) + + def test_format_global_summary_table_without_mcdc(self): + """Global table without MC/DC must display lines and functions only.""" + metrics = DirectoryCoverageMetrics( + name="global", + lines_tot=500, + lines_cov=400, + func_tot=50, + func_cov=40, + ) + summary = FullCoverageSummary(global_stats=metrics) + table = format_global_summary_table(summary) + + self.assertIn("| **Executable Lines** | 400 | 500 | **80.00%** |", table) + self.assertIn("| **Functions** | 40 | 50 | **80.00%** |", table) + self.assertNotIn("MC/DC", table) + + def test_format_global_summary_table_with_mcdc(self): + """Global table with MC/DC must include condition independence and decision verification.""" + metrics = DirectoryCoverageMetrics( + name="global", + lines_tot=1000, + lines_cov=900, + func_tot=100, + func_cov=95, + mcdc_tot=80, + mcdc_cov=60, + decisions_tot=30, + decisions_full=25, + ) + summary = FullCoverageSummary(global_stats=metrics) + table = format_global_summary_table(summary) + + self.assertIn( + "| **MC/DC Condition Independence** | 60 | 80 | **75.00%** |", table + ) + self.assertIn("| **Fully Verified Decisions** | 25 | 30 | **83.33%** |", table) + + def test_format_directory_breakdown_table_alphabetical_sorting(self): + """Directory breakdown table must sort directory names alphabetically.""" + dir_string = DirectoryCoverageMetrics( + name="src/string", lines_tot=100, lines_cov=100 + ) + dir_math = DirectoryCoverageMetrics( + name="src/math", lines_tot=100, lines_cov=80 + ) + dir_ctype = DirectoryCoverageMetrics( + name="src/ctype", lines_tot=100, lines_cov=90 + ) + + summary = FullCoverageSummary( + global_stats=DirectoryCoverageMetrics(lines_tot=300, lines_cov=270), + directories={ + "src/string": dir_string, + "src/math": dir_math, + "src/ctype": dir_ctype, + }, + ) + table = format_directory_breakdown_table(summary) + + # Check alphabetical order in markdown output + idx_ctype = table.find("`libc/src/ctype`") + idx_math = table.find("`libc/src/math`") + idx_string = table.find("`libc/src/string`") + + self.assertTrue(0 <= idx_ctype < idx_math < idx_string) + + def test_format_directory_breakdown_table_without_mcdc(self): + """Directory table without MC/DC must show line and function coverage columns.""" + dir_math = DirectoryCoverageMetrics( + name="src/math", lines_tot=100, lines_cov=80, func_tot=2, func_cov=2 + ) + summary = FullCoverageSummary( + global_stats=DirectoryCoverageMetrics(lines_tot=100, lines_cov=80), + directories={"src/math": dir_math}, + ) + table = format_directory_breakdown_table(summary) + self.assertNotIn("MC/DC Conditions", table) + self.assertIn("`libc/src/math` | **80.00%** | 100.00% | 100 | 20 |", table) + + def test_format_directory_breakdown_table_with_mcdc(self): + """Directory table with MC/DC must show MC/DC condition columns.""" + dir_math = DirectoryCoverageMetrics( + name="src/math", + lines_tot=100, + lines_cov=80, + mcdc_tot=10, + mcdc_cov=8, + decisions_tot=4, + decisions_full=3, + ) + summary = FullCoverageSummary( + global_stats=DirectoryCoverageMetrics( + lines_tot=100, lines_cov=80, mcdc_tot=10, mcdc_cov=8 + ), + directories={"src/math": dir_math}, + ) + table = format_directory_breakdown_table(summary) + + self.assertIn("MC/DC Conditions", table) + self.assertIn("**80.0%** (8/10)", table) + + def test_format_directory_breakdown_table_mixed_mcdc(self): + """Directories without MC/DC records in an MC/DC run must display N/A.""" + dir_math = DirectoryCoverageMetrics( + name="src/math", + lines_tot=100, + lines_cov=80, + mcdc_tot=10, + mcdc_cov=8, + decisions_tot=4, + decisions_full=3, + ) + dir_ctype = DirectoryCoverageMetrics( + name="src/ctype", + lines_tot=50, + lines_cov=50, + mcdc_tot=0, + mcdc_cov=0, + decisions_tot=0, + decisions_full=0, + ) + summary = FullCoverageSummary( + global_stats=DirectoryCoverageMetrics( + lines_tot=150, lines_cov=130, mcdc_tot=10, mcdc_cov=8 + ), + directories={"src/math": dir_math, "src/ctype": dir_ctype}, + ) + table = format_directory_breakdown_table(summary) + self.assertIn("`libc/src/math` | **80.0%** (8/10) | 3 / 4", table) + self.assertIn("`libc/src/ctype` | N/A | N/A", table) + + +class TestRenderFullReportEndToEnd(unittest.TestCase): + """Tests full Markdown report composition from JSON payload to stdout.""" + + def test_render_empty_payload_fallback(self): + """Empty payload must output fallback message without crashing.""" + buf = io.StringIO() + with redirect_stdout(buf): + render_full_report({}) + output = buf.getvalue() + self.assertIn("## LLVM-libc Full Codebase Coverage Report", output) + self.assertIn("### No Coverage Data Detected", output) + self.assertIn( + "The test execution completed but no coverage profiles were exported.", + output, + ) + + def test_render_complete_report_without_mcdc(self): + """Valid report without MC/DC must render line coverage and summary tables.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/math/sin.cpp", + "summary": { + "lines": {"count": 100, "covered": 90}, + "functions": {"count": 2, "covered": 2}, + }, + } + ] + } + ] + } + buf = io.StringIO() + with redirect_stdout(buf): + render_full_report(payload) + output = buf.getvalue() + + self.assertIn("## LLVM-libc Full Codebase Coverage Report", output) + self.assertIn("### Overall Codebase Coverage: **90.00%**", output) + self.assertIn("`libc/src/math`", output) + self.assertNotIn("MC/DC", output) + + def test_render_complete_report_with_mcdc(self): + """Valid report with MC/DC must render full callouts, global table, and directory table.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/math/sin.cpp", + "summary": { + "lines": {"count": 100, "covered": 90}, + "functions": {"count": 2, "covered": 2}, + "mcdc": {"count": 6, "covered": 6}, + }, + "mcdc_records": [ + [10, 5, 10, 20, 0, 0, 0, 0, 0, [True, True]] + ], + } + ] + } + ] + } + + buf = io.StringIO() + with redirect_stdout(buf): + render_full_report(payload) + output = buf.getvalue() + + self.assertIn("## LLVM-libc Full Codebase Coverage Report", output) + self.assertIn("### Overall Codebase Coverage:", output) + self.assertIn("### Overall", output) + self.assertIn("### Coverage Breakdown", output) + self.assertIn("`libc/src/math`", output) + + +class TestCommandLineInterface(unittest.TestCase): + """Tests CLI invocation, arguments parsing, and file handling.""" + + def test_cli_execution_with_file(self): + """CLI must read JSON coverage file from disk and write report to stdout.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/math/sin.cpp", + "summary": { + "lines": {"count": 50, "covered": 40}, + "functions": {"count": 1, "covered": 1}, + }, + } + ] + } + ] + } + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp_file: + json.dump(payload, tmp_file) + tmp_path = tmp_file.name + + try: + buf = io.StringIO() + with patch.object( + sys, + "argv", + ["codebase_coverage.py", tmp_path, "aabbccdd1122", "main"], + ): + with redirect_stdout(buf): + main() + output = buf.getvalue() + self.assertIn("## LLVM-libc Full Codebase Coverage Report", output) + self.assertIn("`libc/src/math`", output) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + def test_cli_execution_with_minimal_arguments(self): + """CLI must execute successfully when commit SHA and branch ref are omitted.""" + payload = { + "data": [ + { + "files": [ + { + "filename": "/workspace/libc/src/ctype/isalnum.cpp", + "summary": { + "lines": {"count": 20, "covered": 20}, + "functions": {"count": 1, "covered": 1}, + }, + } + ] + } + ] + } + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp_file: + json.dump(payload, tmp_file) + tmp_path = tmp_file.name + + try: + buf = io.StringIO() + with patch.object(sys, "argv", ["codebase_coverage.py", tmp_path]): + with redirect_stdout(buf): + main() + output = buf.getvalue() + self.assertIn("## LLVM-libc Full Codebase Coverage Report", output) + self.assertIn("`libc/src/ctype`", output) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + def test_cli_nonexistent_file_exits_with_error(self): + """CLI must exit with code 1 when targeted file does not exist.""" + stderr_buf = io.StringIO() + with patch.object( + sys, + "argv", + ["codebase_coverage.py", "/nonexistent/path/coverage.json"], + ): + with patch("sys.stderr", stderr_buf): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, 1) + self.assertIn("Error: Failed to parse coverage JSON", stderr_buf.getvalue()) + + def test_cli_invalid_json_exits_with_error(self): + """CLI must exit with code 1 when targeted file contains invalid JSON syntax.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp_file: + tmp_file.write("INVALID JSON CONTENT") + tmp_path = tmp_file.name + + stderr_buf = io.StringIO() + try: + with patch.object(sys, "argv", ["codebase_coverage.py", tmp_path]): + with patch("sys.stderr", stderr_buf): + with self.assertRaises(SystemExit) as cm: + main() + self.assertEqual(cm.exception.code, 1) + self.assertIn("Error: Failed to parse coverage JSON", stderr_buf.getvalue()) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/libc/utils/coverage/test_diff_coverage.py b/libc/utils/coverage/test_diff_coverage.py new file mode 100644 index 0000000000000..e369a6a5f7004 --- /dev/null +++ b/libc/utils/coverage/test_diff_coverage.py @@ -0,0 +1,624 @@ +# ====- Unit tests for diff_coverage.py ----------------------*- python -*--==# +# +# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ==-------------------------------------------------------------------------==# + +"""Unit tests for diff_coverage.py.""" + +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from diff_coverage import ( + DEFAULT_BASE_REPOSITORY, + DEFAULT_HEAD_REPOSITORY, + CoverageJSONParser, + DiffHunk, + DiffParser, + FilePatchMetrics, + PatchCoverageSummary, + calculate_patch_statistics, + format_annotated_diff, + format_breakdown_table, + format_line_ranges, + format_metadata_section, + format_status_banner, + is_executable_line, + main, + render_patch_report, +) + + +def _make_cov_json(filename, segments, mcdc_records=None): + """Constructs a minimal llvm-cov JSON export payload.""" + return { + "data": [ + { + "files": [ + { + "filename": filename, + "segments": segments, + "mcdc_records": mcdc_records or [], + } + ] + } + ] + } + + +class TestCoverageDataStructures(unittest.TestCase): + """Tests FilePatchMetrics and PatchCoverageSummary ratio logic and zero-division guards.""" + + def test_file_patch_metrics_calculations(self): + """FilePatchMetrics must correctly compute line and MC/DC ratios or 0.0 on zero totals.""" + empty = FilePatchMetrics(file_path="src/math/sin.cpp") + self.assertEqual(empty.total_lines, 0) + self.assertEqual(empty.line_coverage_percentage, 0.0) + self.assertEqual(empty.mcdc_coverage_percentage, 0.0) + + metrics = FilePatchMetrics( + file_path="src/math/sin.cpp", + covered_lines={10, 11, 12}, + missed_lines={13}, + mcdc_covered_conditions=3, + mcdc_total_conditions=4, + ) + self.assertEqual(metrics.total_lines, 4) + self.assertAlmostEqual(metrics.line_coverage_percentage, 75.0, places=2) + self.assertAlmostEqual(metrics.mcdc_coverage_percentage, 75.0, places=2) + + def test_patch_coverage_summary_aggregation(self): + """PatchCoverageSummary must correctly aggregate metrics and condition indicators.""" + empty = PatchCoverageSummary() + self.assertEqual(empty.total_lines, 0) + self.assertEqual(empty.line_coverage_percentage, 0.0) + self.assertEqual(empty.mcdc_coverage_percentage, 0.0) + self.assertFalse(empty.has_mcdc) + + summary = PatchCoverageSummary( + total_covered_lines=6, + total_missed_lines=2, + total_mcdc_covered_conditions=1, + total_mcdc_total_conditions=2, + ) + self.assertEqual(summary.total_lines, 8) + self.assertAlmostEqual(summary.line_coverage_percentage, 75.0, places=2) + self.assertAlmostEqual(summary.mcdc_coverage_percentage, 50.0, places=2) + self.assertTrue(summary.has_mcdc) + + +class TestExecutableLineFiltering(unittest.TestCase): + """Tests statement heuristics distinguishing executable C++ statements from non-code.""" + + def test_statement_heuristics(self): + """Verifies statement classification across distinct C/C++ syntactic forms.""" + test_cases = [ + ("int x = 42;", True, "Variable assignment"), + ("x += y;", True, "Compound arithmetic assignment"), + ("return result;", True, "Return statement"), + ("if (x > 0) {", True, "Branch condition header"), + ("for (size_t i = 0; i < count; ++i) {", True, "For loop header"), + ("do_work(a, b);", True, "Function call"), + ("struct Point p = {1, 2};", True, "Struct variable assignment"), + ("int x = 42; // assignment", True, "Statement with line comment"), + ("int x = 42; /* inline comment */", True, "Statement with block comment"), + ("// Single line comment", False, "Line comment"), + ("/* Block comment start", False, "Block comment start"), + (" * Continuation line", False, "Block comment middle"), + (" */", False, "Block comment end"), + ( + "} // namespace LIBC_NAMESPACE_DECL", + False, + "Brace with namespace comment", + ), + ("}; // struct Point", False, "Scope end with comment"), + ("{ // begin loop", False, "Opening brace with comment"), + ("} /* namespace */", False, "Brace with block comment"), + ("public:", False, "Access specifier"), + ("private: // methods", False, "Access specifier with comment"), + ( + 'static_assert(sizeof(long) == 8, "msg");', + False, + "Compile-time assertion", + ), + ("friend class Peer;", False, "Friend declaration"), + ("{", False, "Opening brace"), + ("}", False, "Closing brace"), + ("};", False, "Scope terminator"), + (": value_(0) {", False, "Constructor initializer header"), + ("#include ", False, "Preprocessor include"), + ("namespace LIBC_NAMESPACE {", False, "Namespace definition"), + ("using size_t = unsigned long;", False, "Type alias"), + ("struct ListNode;", False, "Forward struct declaration"), + ("enum class Status : uint8_t {", False, "Enum definition header"), + ("", False, "Empty line"), + (" ", False, "Whitespace line"), + ] + for line, expected, desc in test_cases: + with self.subTest(msg=desc, line=line): + self.assertEqual(is_executable_line(line), expected) + + +class TestFormatLineRanges(unittest.TestCase): + """Tests line number set formatting into concise span representations.""" + + def test_formatting_spans(self): + """Line number sets must format as empty, single, contiguous, or disjoint spans.""" + self.assertEqual(format_line_ranges(set()), "None") + self.assertEqual(format_line_ranges({42}), "`L42`") + self.assertEqual(format_line_ranges({10, 11, 12}), "`L10-L12`") + disjoint = {1, 2, 5, 8, 9, 100} + self.assertEqual(format_line_ranges(disjoint), "`L1-L2`, `L5`, `L8-L9`, `L100`") + + +class TestDiffParser(unittest.TestCase): + """Tests Unified Diff parsing across single/multiple hunks, creations, and deletions.""" + + def test_parse_diff_hunks(self): + """DiffParser must extract added and context lines across hunks while ignoring deletions.""" + diff_text = ( + "diff --git a/src/math/sin.cpp b/src/math/sin.cpp\n" + "--- a/src/math/sin.cpp\n" + "+++ b/src/math/sin.cpp\n" + "@@ -10,3 +10,4 @@\n" + " ctx1();\n" + "-deleted();\n" + "+added1();\n" + "+added2();\n" + "@@ -50,1 +51,2 @@\n" + " ctx2();\n" + "+added3();\n" + ) + parsed = DiffParser.parse(diff_text) + self.assertIn("src/math/sin.cpp", parsed) + hunks = parsed["src/math/sin.cpp"] + self.assertEqual(len(hunks), 2) + added_hunk1 = [line for line in hunks[0].lines if line[0] == "+"] + added_hunk2 = [line for line in hunks[1].lines if line[0] == "+"] + self.assertEqual(added_hunk1, [("+", "added1();", 11), ("+", "added2();", 12)]) + self.assertEqual(added_hunk2, [("+", "added3();", 52)]) + + def test_parse_special_files(self): + """Newly created files must start at line 1, deleted files and headers must be skipped.""" + diff_text = ( + "diff --git a/src/math/new.cpp b/src/math/new.cpp\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/src/math/new.cpp\n" + "index 0000..1111\n" + "@@ -0,0 +1,1 @@\n" + "+int new_func();\n" + "diff --git a/src/math/old.cpp b/src/math/old.cpp\n" + "--- a/src/math/old.cpp\n" + "+++ /dev/null\n" + "@@ -1,1 +0,0 @@\n" + "-deleted();\n" + ) + parsed = DiffParser.parse(diff_text) + self.assertIn("src/math/new.cpp", parsed) + self.assertNotIn("src/math/old.cpp", parsed) + self.assertEqual( + parsed["src/math/new.cpp"][0].lines[0], ("+", "int new_func();", 1) + ) + self.assertEqual(DiffParser.parse(""), {}) + + def test_parse_from_disk_file(self): + """DiffParser must successfully read and parse diff files from disk.""" + diff_text = "diff --git a/a b/b\n+++ b/src/math/f.cpp\n@@ -1,1 +1,2 @@\n ctx();\n+line();\n" + with tempfile.NamedTemporaryFile(mode="w", suffix=".diff", delete=False) as tmp: + tmp.write(diff_text) + tmp_path = tmp.name + try: + parsed = DiffParser.parse(tmp_path) + self.assertIn("src/math/f.cpp", parsed) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +class TestCoverageJSONParser(unittest.TestCase): + """Tests JSON parsing, segment expansion, MC/DC extraction, and loader safeguards.""" + + def test_segment_expansion_and_path_normalization(self): + """Segments spanning multiple lines must mark each line covered, uncounted must skip.""" + diff_files = {"libc/src/math/sin.cpp": [], "src/math/cos.cpp": []} + json_data = { + "data": [ + { + "files": [ + { + "filename": "/runner/work/llvm-project/libc/src/math/sin.cpp", + "segments": [ + [10, 0, 5, 1, 1], + [13, 0, 0, 1, 1], + [20, 0, 0, 0, 1], + ], + "mcdc_records": [], + }, + { + # Tests target_path.endswith("/" + file_name) + "filename": "cos.cpp", + "segments": [[5, 0, 1, 1, 1]], + "mcdc_records": [], + }, + { + # Unmatched file: tests continue branch + "filename": "/runner/work/llvm-project/libc/src/math/other.cpp", + "segments": [[1, 0, 1, 1, 1]], + "mcdc_records": [], + }, + ] + } + ] + } + matrix = CoverageJSONParser.extract_patch_matrix(json_data, diff_files) + covered = matrix["libc/src/math/sin.cpp"]["covered"] + missed = matrix["libc/src/math/sin.cpp"]["missed"] + self.assertEqual(covered, {10, 11, 12}) + self.assertIn(13, missed) + self.assertIn(19, missed) + self.assertNotIn(20, missed) + + def test_mcdc_records_extraction(self): + """Valid MC/DC records must be extracted; truncated or empty records must be ignored.""" + diff_files = {"src/math/sin.cpp": []} + json_data = _make_cov_json( + filename="/workspace/src/math/sin.cpp", + segments=[[10, 0, 1, 1, 1]], + mcdc_records=[ + [10, 4, 10, 14, 0, 0, 0, 0, 0, [True, False]], # Valid + [11, 4, 11, 14], # Truncated (< 10) + [12, 4, 12, 14, 0, 0, 0, 0, 0, []], # Empty condition vector + ], + ) + matrix = CoverageJSONParser.extract_patch_matrix(json_data, diff_files) + decisions = matrix["src/math/sin.cpp"]["mcdc_decisions"] + self.assertEqual(len(decisions), 1) + self.assertEqual(decisions[0]["line_start"], 10) + self.assertEqual(decisions[0]["covered"], 1) + self.assertEqual(decisions[0]["total"], 2) + + def test_load_and_empty_payload_safeguards(self): + """CoverageJSONParser must load valid JSON, fallback on empty data, and exit on error.""" + diff_files = {"src/math/sin.cpp": []} + empty_matrix = CoverageJSONParser.extract_patch_matrix({}, diff_files) + self.assertEqual(len(empty_matrix["src/math/sin.cpp"]["covered"]), 0) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + json.dump({"key": "val"}, tmp) + tmp_path = tmp.name + try: + self.assertEqual(CoverageJSONParser.load(tmp_path), {"key": "val"}) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + stderr_buf = io.StringIO() + with patch("sys.stderr", stderr_buf): + with self.assertRaises(SystemExit): + CoverageJSONParser.load("/nonexistent/cov.json") + self.assertIn("Error: Failed to parse coverage JSON", stderr_buf.getvalue()) + + +class TestCalculatePatchStatistics(unittest.TestCase): + """Tests correlating patch lines against coverage segments and MC/DC truth tables.""" + + def test_calculate_patch_statistics(self): + """Covered lines take precedence, uninstrumented files miss, and MC/DC diagnoses unverified.""" + diff_text = ( + "diff --git a/src/math/sin.cpp b/src/math/sin.cpp\n" + "--- a/src/math/sin.cpp\n" + "+++ b/src/math/sin.cpp\n" + "@@ -10,2 +10,3 @@\n" + " ctx();\n" + "+int covered_and_missed = 1;\n" + "+if (a && b) return 1;\n" + "diff --git a/src/math/untested.cpp b/src/math/untested.cpp\n" + "--- a/src/math/untested.cpp\n" + "+++ b/src/math/untested.cpp\n" + "@@ -1,1 +1,2 @@\n" + " ctx();\n" + "+int untested = 1;\n" + ) + diff_files = DiffParser.parse(diff_text) + coverage_matrix = { + "src/math/sin.cpp": { + "covered": {11, 12}, + "missed": {11}, # Covered takes precedence + "mcdc_decisions": [ + { + "line_start": 12, + "line_end": 12, + "conditions": [True, False], + "covered": 1, + "total": 2, + } + ], + }, + "src/math/untested.cpp": { + "covered": set(), + "missed": set(), + "mcdc_decisions": [], + }, + } + summary = calculate_patch_statistics(diff_files, coverage_matrix) + self.assertEqual(summary.total_lines, 3) + self.assertEqual(summary.total_covered_lines, 2) + self.assertEqual(summary.total_missed_lines, 1) + self.assertEqual(summary.total_mcdc_covered_conditions, 1) + self.assertEqual(summary.total_mcdc_total_conditions, 2) + + sin_metric = summary.files["src/math/sin.cpp"] + self.assertIn( + "1/2 verified (C2 unverified)", sin_metric.condition_diagnostics[0] + ) + self.assertEqual(sin_metric.unverified_decision_lines[12], ["C2"]) + + def test_non_source_and_comment_files_skipped(self): + """Test files, documentation, and files with only comment additions must be skipped.""" + diff_text = ( + "diff --git a/libc/test/src/math/sin_test.cpp b/libc/test/src/math/sin_test.cpp\n" + "+++ b/libc/test/src/math/sin_test.cpp\n" + "@@ -1,1 +1,2 @@\n" + "+TEST(Foo, Bar) {}\n" + "diff --git a/src/math/comment_only.cpp b/src/math/comment_only.cpp\n" + "+++ b/src/math/comment_only.cpp\n" + "@@ -1,1 +1,2 @@\n" + "+// comment only\n" + ) + diff_files = DiffParser.parse(diff_text) + coverage_matrix = { + "libc/test/src/math/sin_test.cpp": { + "covered": set(), + "missed": set(), + "mcdc_decisions": [], + }, + "src/math/comment_only.cpp": { + "covered": set(), + "missed": set(), + "mcdc_decisions": [], + }, + } + summary = calculate_patch_statistics(diff_files, coverage_matrix) + self.assertEqual(summary.total_lines, 0) + self.assertEqual(len(summary.files), 0) + + +class TestPatchReportFormatting(unittest.TestCase): + """Tests Markdown formatting across status banners, metadata, tables, and annotated diffs.""" + + def test_format_status_banner_variants(self): + """Verifies phrasing across all 5 status banner operational conditions.""" + cases = [ + ( + PatchCoverageSummary(total_covered_lines=5, total_missed_lines=0), + "All **5** newly added", + ), + ( + PatchCoverageSummary( + total_covered_lines=5, + total_missed_lines=0, + total_mcdc_covered_conditions=2, + total_mcdc_total_conditions=2, + total_decisions_count=1, + fully_verified_decisions=1, + ), + "All **5** executable lines and **2** boolean conditions", + ), + ( + PatchCoverageSummary( + total_covered_lines=5, + total_missed_lines=0, + total_mcdc_covered_conditions=1, + total_mcdc_total_conditions=2, + total_decisions_count=1, + fully_verified_decisions=0, + ), + "Executed **5 / 5** lines. **1 / 2** boolean conditions", + ), + ( + PatchCoverageSummary(total_covered_lines=4, total_missed_lines=1), + "Executed **4 / 5** lines (**1** unexecuted", + ), + ( + PatchCoverageSummary( + total_covered_lines=4, + total_missed_lines=1, + total_mcdc_covered_conditions=1, + total_mcdc_total_conditions=2, + total_decisions_count=1, + fully_verified_decisions=0, + ), + "(**1** unexecuted lines detected in patch).", + ), + ] + for summary, expected in cases: + with self.subTest(expected=expected): + self.assertIn(expected, format_status_banner(summary)) + + def test_format_metadata_section(self): + """Metadata section must format commits, tests, or return empty on missing arguments.""" + metadata = format_metadata_section( + "1111111", "2222222", "main", "patch", "test_target" + ) + self.assertIn("Base Branch", metadata) + self.assertIn("`test_target`", metadata) + self.assertEqual(format_metadata_section(None, None, None, None), "") + + def test_format_breakdown_table(self): + """Breakdown tables must render line/MCDC stats and normalize paths to libc/ on GitHub.""" + file_mcdc = FilePatchMetrics( + file_path="src/math/sin.cpp", + covered_lines={10}, + missed_lines=set(), + added_lines={10}, + mcdc_covered_conditions=2, + mcdc_total_conditions=2, + decisions_verified=1, + decisions_total=1, + condition_diagnostics=["`L10`: 2/2 verified"], + ) + file_no_mcdc = FilePatchMetrics( + file_path="src/string/strlen.cpp", + covered_lines={20}, + missed_lines={21}, + added_lines={20, 21}, + ) + summary = PatchCoverageSummary( + total_covered_lines=2, + total_missed_lines=1, + total_mcdc_covered_conditions=2, + total_mcdc_total_conditions=2, + fully_verified_decisions=1, + total_decisions_count=1, + files={ + "src/math/sin.cpp": file_mcdc, + "src/string/strlen.cpp": file_no_mcdc, + }, + ) + table = format_breakdown_table(summary, head_commit_sha="abcd123") + self.assertIn("blob/abcd123/libc/src/math/sin.cpp", table) + self.assertIn("MC/DC Conditions", table) + self.assertIn("N/A | N/A", table) # strlen has no MC/DC + + def test_format_annotated_diff(self): + """Annotated diff must output covered, missed, partial MC/DC, non-executable, and context lines.""" + diff_text = ( + "diff --git a/src/math/sin.cpp b/src/math/sin.cpp\n" + "+++ b/src/math/sin.cpp\n" + "@@ -10,4 +10,5 @@\n" + " ctx();\n" + "+covered();\n" + "+missed();\n" + "+if (a && b) {}\n" + "+{\n" + ) + diff_files = DiffParser.parse(diff_text) + file_metrics = FilePatchMetrics( + file_path="src/math/sin.cpp", + covered_lines={11, 13}, + missed_lines={12}, + unverified_decision_lines={13: ["C2"]}, + ) + summary = PatchCoverageSummary(files={"src/math/sin.cpp": file_metrics}) + annotated = format_annotated_diff(summary, diff_files) + self.assertIn(" ctx();", annotated) + self.assertIn("+ covered();", annotated) + self.assertIn("- missed(); // [MISSED]", annotated) + self.assertIn("! if (a && b) {} // [PARTIAL MC/DC: C2 unverified]", annotated) + self.assertIn(" {", annotated) + + +class TestRenderPatchReportEndToEnd(unittest.TestCase): + """Tests full Markdown report composition from inputs to stdout.""" + + def test_render_empty_diff(self): + """Empty diff must render coverage notice without failing.""" + buf = io.StringIO() + with redirect_stdout(buf): + render_patch_report({}, {}, "1111", "2222", "main", "feature") + self.assertIn( + "No executable lines were added or modified in this patch.", buf.getvalue() + ) + + def test_render_full_report_with_mcdc(self): + """Patch report with MC/DC must display the MC/DC report title and full tables.""" + diff_text = "diff --git a/src/math/f.cpp b/src/math/f.cpp\n+++ b/src/math/f.cpp\n@@ -1,1 +1,2 @@\n ctx();\n+return a && b;\n" + diff_files = DiffParser.parse(diff_text) + coverage_matrix = { + "src/math/f.cpp": { + "covered": {2}, + "missed": set(), + "mcdc_decisions": [ + { + "line_start": 2, + "line_end": 2, + "conditions": [True, True], + "covered": 2, + "total": 2, + } + ], + } + } + buf = io.StringIO() + with redirect_stdout(buf): + render_patch_report( + diff_files, coverage_matrix, "1111", "2222", "main", "feature" + ) + output = buf.getvalue() + self.assertIn("## LLVM-libc MC/DC Patch Coverage Report", output) + self.assertIn( + "### Patch Coverage: **100.00% Line** | **100.00% MC/DC**", output + ) + self.assertIn("View Annotated Patch Diff", output) + + +class TestCommandLineInterfaceDiff(unittest.TestCase): + """Tests CLI execution and file validation safeguards.""" + + def test_cli_execution(self): + """CLI must read diff and JSON files from disk and print the report to stdout.""" + diff_text = "diff --git a/src/math/s.cpp b/src/math/s.cpp\n+++ b/src/math/s.cpp\n@@ -1,1 +1,2 @@\n ctx();\n+int x = 1;\n" + json_data = _make_cov_json("/workspace/src/math/s.cpp", [[2, 0, 1, 1, 1]]) + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".diff", delete=False + ) as f_diff: + f_diff.write(diff_text) + path_diff = f_diff.name + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as f_json: + json.dump(json_data, f_json) + path_json = f_json.name + + try: + buf = io.StringIO() + with patch.object( + sys, + "argv", + [ + "diff_coverage.py", + path_diff, + path_json, + "111", + "222", + "m", + "f", + "target", + ], + ): + with redirect_stdout(buf): + main() + self.assertIn("## LLVM-libc Patch Coverage Report", buf.getvalue()) + self.assertIn("[`src/math/s.cpp`]", buf.getvalue()) + finally: + if os.path.exists(path_diff): + os.remove(path_diff) + if os.path.exists(path_json): + os.remove(path_json) + + def test_cli_missing_files_exit(self): + """CLI must exit with code 1 when diff file or JSON file is missing.""" + stderr_buf = io.StringIO() + with patch.object( + sys, "argv", ["diff_coverage.py", "/missing.diff", "/missing.json"] + ): + with patch("sys.stderr", stderr_buf): + with self.assertRaises(SystemExit): + main() + self.assertIn("Error: Diff file not found", stderr_buf.getvalue()) + + +if __name__ == "__main__": + unittest.main()