diff --git a/.github/codecov/verification.gpg.asc b/.github/codecov/verification.gpg.asc deleted file mode 100644 index b7dfce0ee56..00000000000 --- a/.github/codecov/verification.gpg.asc +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQINBGCsMn0BEACiCKZOhkbhUjb+obvhH49p3ShjJzU5b/GqAXSDhRhdXUq7ZoGq -KEKCd7sQHrCf16Pi5UVacGIyE9hS93HwY15kMlLwM+lNeAeCglEscOjpCly1qUIr -sN1wjkd2cwDXS6zHBJTqJ7wSOiXbZfTAeKhd6DuLEpmA+Rz4Yc+4qZP+fVxVG3Pv -2v06m+E5CP/JQVQPO8HYi+S36hJImTh+zaDspu+VujSai5KzJ6YKmgwslVNIp5X5 -GnEr2uAh5w6UTnt9UQUjFFliAvQ3lPLWzm7DWs6AP9hslYxSWzwbzVF5qbOIjUJL -KfoUpvCYDs2ObgRn8WUQO0ndkRCBIxhlF3HGGYWKQaCEsiom7lyi8VbAszmUCDjw -HdbQHFmm5yHLpTXJbg+iaxQzKnhWVXzye5/x92IJmJswW81Ky346VxYdC1XFL/+Y -zBaj9oMmV7WfRpdch09Gf4TgosMzWf3NjJbtKE5xkaghJckIgxwzcrRmF/RmCJue -IMqZ8A5qUUlK7NBzj51xmAQ4BtkUa2bcCBRV/vP+rk9wcBWz2LiaW+7Mwlfr/C/Q -Swvv/JW2LsQ4iWc1BY7m7ksn9dcdypEq/1JbIzVLCRDG7pbMj9yLgYmhe5TtjOM3 -ygk25584EhXSgUA3MZw+DIqhbHQBYgrKndTr2N/wuBQY62zZg1YGQByD4QARAQAB -tEpDb2RlY292IFVwbG9hZGVyIChDb2RlY292IFVwbG9hZGVyIFZlcmlmaWNhdGlv -biBLZXkpIDxzZWN1cml0eUBjb2RlY292LmlvPokCTgQTAQoAOBYhBCcDTn/bhQ4L -vCxi/4Brsortd5hpBQJgrDJ9AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJ -EIBrsortd5hpxLMP/3Fbgx5EG7zUUOqPZ+Ya9z8JlZFIkh3FxYMfMFE8jH9Es26F -V2ZTJLO259MxM+5N0XzObi3h4XqIzBn42pDRfwtojY5wl2STJ9Bzu+ykPog7OB1u -yfWXDRKcqPTUIxI1/WdU+c0/WNE6wjyzK+lRc1YUlp4pdNU7l+j2vKN+jGi2b6nV -PTPRsMcwy3B90fKf5h2wNMNqO+KX/rjgpG9Uhej+xyFWkGM1tZDQQYFj+ugQUj61 -BMsQrUmxOnaVVnix21cHnACDCaxqgQZH3iZyEOKPNMsRFRP+0fLEnUMP+DVnQE6J -Brk1Z+XhtjGI9PISQVx5KKDKscreS/D5ae2Cw/FUlQMf57kir6mkbZVhz2khtccz -atD0r59WomNywIDyk1QfAKV0+O0WeJg8A69/Jk6yegsrUb5qEfkih/I38vvI0OVL -BYve/mQIHuQo5ziBptNytCrN5TXHXzguX9GOW1V1+3DR+w/vXcnz67sjlYDysf1f -JUZv9edZ2RGKW7agbrgOw2hB+zuWZ10tjoEcsaSGOLtKRGFDfmu/dBxzl8yopUpa -Tn79QKOieleRm5+uCcKCPTeKV0GbhDntCZJ+Yiw6ZPmrpcjDowAoMQ9kiMVa10+Q -WwwoaRWuqhf+dL6Q2OLFOxlyCDKVSyW0YF4Vrf3fKGyxKJmszAL+NS1mVcdxuQIN -BGCsMn0BEADLrIesbpfdAfWRvUFDN+PoRfa0ROwa/JOMhEgVsowQuk9No8yRva/X -VyiA6oCq6na7IvZXMxT7di4FWDjDtw5xHjbtFg336IJTGBcnzm7WIsjvyyw8kKfB -8cvG7D2OkzAUF8SVXLarJ1zdBP/Dr1Nz6F/gJsx5+BM8wGHEz4DsdMRV7ZMTVh6b -PaGuPZysPjSEw62R8MFJ1fSyDGCKJYwMQ/sKFzseNaY/kZVR5lq0dmhiYjNVQeG9 -HJ6ZCGSGT5PKNOwx/UEkT6jhvzWgfr2eFVGJTcdwSLEgIrJIDzP7myHGxuOiuCmJ -ENgL1f7mzGkJ/hYXq1RWqsn1Fh2I9KZMHggqu4a+s3RiscmNcbIlIhJLXoE1bxZ/ -TfYZ9Aod6Bd5TsSMTZNwV2am9zelhDiFF60FWww/5nEbhm/X4suC9W86qWBxs3Kh -vk1dxhElRjtgwUEHA5OFOO48ERHfR7COH719D/YmqLU3EybBgJbGoC/yjlGJxv0R -kOMAiG2FneNKEZZihReh8A5Jt6jYrSoHFRwL6oJIZfLezB7Rdajx1uH7uYcUyIaE -SiDWlkDw/IFM315NYFA8c1TCSIfnabUYaAxSLNFRmXnt+GQpm44qAK1x8EGhY633 -e5B4FWorIXx0tTmsVM4rkQ6IgAodeywKG+c2Ikd+5dQLFmb7dW/6CwARAQABiQI2 -BBgBCgAgFiEEJwNOf9uFDgu8LGL/gGuyiu13mGkFAmCsMn0CGwwACgkQgGuyiu13 -mGkYWxAAkzF64SVpYvY9nY/QSYikL8UHlyyqirs6eFZ3Mj9lMRpHM2Spn9a3c701 -0Ge4wDbRP2oftCyPP+p9pdUA77ifMTlRcoMYX8oXAuyE5RT2emBDiWvSR6hQQ8bZ -WFNXal+bUPpaRiruCCUPD2b8Od1ftzLqbYOosxr/m5Du0uahgOuGw6zlGBJCVOo7 -UB2Y++oZ8P7oDGF722opepWQ+bl2a6TRMLNWWlj4UANknyjlhyZZ7PKhWLjoC6MU -dAKcwQUdp+XYLc/3b00bvgju0e99QgHZMX2fN3d3ktdN5Q2fqiAi5R6BmCCO4ISF -o5j10gGU/sdqGHvNhv5C21ibun7HEzMtxBhnhGmytfBJzrsj7GOReePsfTLoCoUq -dFMOAVUDciVfRtL2m8cv42ZJOXtPfDjsFOf8AKJk40/tc8mMMqZP7RVBr9RWOoq5 -y9D37NfI6UB8rPZ6qs0a1Vfm8lIh2/k1AFECduXgftMDTsmmXOgXXS37HukGW7AL -QKWiWJQF/XopkXwkyAYpyuyRMZ77oF7nuqLFnl5VVEiRo0Fwu45erebc6ccSwYZU -8pmeSx7s0aJtxCZPSZEKZ3mn0BXOR32Cgs48CjzFWf6PKucTwOy/YO0/4Gt/upNJ -3DyeINcYcKyD08DEIF9f5tLyoiD4xz+N23ltTBoMPyv4f3X/wCQ= -=ch7z ------END PGP PUBLIC KEY BLOCK----- diff --git a/.github/coverage-thresholds.json b/.github/coverage-thresholds.json new file mode 100644 index 00000000000..d34c1627305 --- /dev/null +++ b/.github/coverage-thresholds.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "scope": "src/coreComponents", + "minimum_basis_points": { + "lines": 7001, + "functions": 7501, + "branches": 5001 + } +} diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 4b746e51a9a..d921fbfa038 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -20,10 +20,18 @@ on: required: false type: string default: '' - CODE_COVERAGE: + LLVM_SOURCE_COVERAGE: required: false type: boolean default: false + COVERAGE_BASE_SHA: + required: false + type: string + default: '' + COVERAGE_HEAD_SHA: + required: false + type: string + default: '' CTEST_PARALLEL_LEVEL: required: false type: string @@ -88,8 +96,6 @@ on: required: false type: string secrets: - CODECOV_TOKEN: - required: false GOOGLE_CLOUD_GCP: required: false jobs: @@ -135,7 +141,9 @@ jobs: with: submodules: true lfs: ${{ inputs.BUILD_TYPE == 'integrated_tests' }} - fetch-depth: 1 + # A coverage PR is built from GitHub's merge commit. Its two parents + # are needed to attribute coverage to the exact patch that was tested. + fetch-depth: ${{ inputs.LLVM_SOURCE_COVERAGE && 2 || 1 }} - id: 'auth' if: ${{ (inputs.UPLOAD_TO_GCP && inputs.GCP_BUCKET) || (inputs.USE_SCCACHE && !startsWith(inputs.RUNS_ON, 'streak2')) }} @@ -144,18 +152,6 @@ jobs: credentials_json: '${{ secrets.GOOGLE_CLOUD_GCP }}' create_credentials_file: true - - name: Prepare Codecov verification key - if: inputs.CODE_COVERAGE - run: | - set -euxo pipefail - GNUPGHOME="${RUNNER_TEMP}/codecov-gnupg" - export GNUPGHOME - mkdir -p "${GNUPGHOME}" - chmod 700 "${GNUPGHOME}" - gpg --batch --import "${GITHUB_WORKSPACE}/.github/codecov/verification.gpg.asc" || true - gpg --batch --list-keys 806BB28AED779869 >/dev/null - echo "GNUPGHOME=${GNUPGHOME}" >> "${GITHUB_ENV}" - - name: 'Set up Cloud SDK' if: inputs.UPLOAD_TO_GCP && inputs.GCP_BUCKET uses: 'google-github-actions/setup-gcloud@v2.1.4' @@ -163,12 +159,25 @@ jobs: version: '>= 363.0.0' - name: Build, test, deploy. + env: + CI_DOCKER_IMAGE_TAG: ${{ inputs.DOCKER_IMAGE_TAG }} + CI_DOCKER_REPOSITORY: ${{ inputs.DOCKER_REPOSITORY }} + CI_LLVM_SOURCE_COVERAGE: ${{ inputs.LLVM_SOURCE_COVERAGE }} + CI_RUNNER_LABEL: ${{ inputs.RUNS_ON }} + CI_COVERAGE_BASE_SHA: ${{ inputs.COVERAGE_BASE_SHA }} + CI_COVERAGE_HEAD_SHA: ${{ inputs.COVERAGE_HEAD_SHA }} + CI_REPOSITORY: ${{ github.repository }} + # This standard, read-only Actions token is used only by the bounded + # host-side baseline fetcher and is never passed into Docker. + GITHUB_TOKEN: ${{ github.token }} id: build_test_deploy run: | # Those two bash arrays will be populated depending on the required options, # and expended as CLI arguments for the docker and scripts calls. docker_args=() script_args=() + DOCKER_IMAGE="${CI_DOCKER_REPOSITORY}:${CI_DOCKER_IMAGE_TAG}" + DOCKER_RUN_IMAGE="${DOCKER_IMAGE}" # Keep the repository mount point consistent between Docker and script args. GITHUB_WORKSPACE_MOUNT_POINT=/tmp/geos @@ -184,7 +193,7 @@ jobs: docker_args+=(${{ inputs.DOCKER_RUN_ARGS }}) - COMMIT=${{ github.event.pull_request.head.sha }} + COMMIT=${{ github.event.pull_request.head.sha || github.sha }} SHORT_COMMIT=${COMMIT:0:7} script_args+=(--install-dir-basename GEOS-${SHORT_COMMIT}) @@ -245,6 +254,91 @@ jobs: docker_args+=(--volume=${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE_MOUNT_POINT}) script_args+=(--repository ${GITHUB_WORKSPACE_MOUNT_POINT}) + if [[ "${CI_LLVM_SOURCE_COVERAGE}" == 'true' ]]; then + coverage_base_sha="${CI_COVERAGE_BASE_SHA}" + coverage_head_sha="${CI_COVERAGE_HEAD_SHA}" + if [[ ! "${coverage_head_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Coverage head SHA is missing or invalid: ${coverage_head_sha}" >&2 + exit 1 + fi + if [[ "$(git rev-parse HEAD)" != "${coverage_head_sha}" ]]; then + echo "Coverage checkout does not match the tested head SHA." >&2 + exit 1 + fi + + if [[ -n "${coverage_base_sha}" ]]; then + if [[ ! "${coverage_base_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Coverage base SHA is invalid: ${coverage_base_sha}" >&2 + exit 1 + fi + if ! git cat-file -e "${coverage_base_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "${coverage_base_sha}" + fi + if ! git cat-file -e "${coverage_base_sha}^{commit}" 2>/dev/null; then + echo "Coverage base commit is unavailable after fetching it." >&2 + exit 1 + fi + if [[ "$(git rev-parse "${coverage_head_sha}^1")" != "${coverage_base_sha}" ]]; then + echo "Coverage base SHA is not the first parent of the tested merge commit." >&2 + exit 1 + fi + fi + + COVERAGE_ARTIFACT_DIR=$(mktemp -d "${RUNNER_TEMP}/llvm-source-coverage.XXXXXX") + if [[ ! -d "${COVERAGE_ARTIFACT_DIR}" || -L "${COVERAGE_ARTIFACT_DIR}" ]]; then + echo "Failed to create a safe LLVM coverage artifact directory." >&2 + exit 1 + fi + printf 'LLVM_COVERAGE_ARTIFACT_DIR=%s\n' "${COVERAGE_ARTIFACT_DIR}" >> "${GITHUB_ENV}" + COVERAGE_ARTIFACT_MOUNT_POINT=/tmp/llvm-source-coverage-artifact + docker_args+=(--volume=${COVERAGE_ARTIFACT_DIR}:${COVERAGE_ARTIFACT_MOUNT_POINT}) + docker pull "${DOCKER_IMAGE}" + docker_image_id=$(docker image inspect --format '{{.Id}}' "${DOCKER_IMAGE}") + docker_image_digests=$(docker image inspect --format '{{json .RepoDigests}}' "${DOCKER_IMAGE}") + DOCKER_RUN_IMAGE="${docker_image_id}" + docker_args+=(-e "GEOS_CI_CONTAINER_IMAGE=${DOCKER_IMAGE}") + docker_args+=(-e "GEOS_CI_CONTAINER_IMAGE_ID=${docker_image_id}") + docker_args+=(-e "GEOS_CI_CONTAINER_IMAGE_DIGESTS=${docker_image_digests}") + docker_args+=(-e "GEOS_CI_RUNNER_LABEL=${CI_RUNNER_LABEL}") + script_args+=(--llvm-source-coverage + --coverage-output-dir "${COVERAGE_ARTIFACT_MOUNT_POINT}" + --coverage-head-sha "${coverage_head_sha}") + + if [[ -n "${coverage_base_sha}" ]]; then + script_args+=(--coverage-base-sha "${coverage_base_sha}") + COVERAGE_BASELINE_DIR=$(mktemp -d "${RUNNER_TEMP}/llvm-coverage-baseline.XXXXXX") + if [[ ! -d "${COVERAGE_BASELINE_DIR}" || -L "${COVERAGE_BASELINE_DIR}" ]]; then + echo "Failed to create a safe coverage baseline directory." >&2 + exit 1 + fi + printf 'LLVM_COVERAGE_BASELINE_DIR=%s\n' "${COVERAGE_BASELINE_DIR}" >> "${GITHUB_ENV}" + + set +e + python3 scripts/fetch_coverage_baseline.py \ + --repository "${CI_REPOSITORY}" \ + --workflow ci_tests.yml \ + --base-sha "${coverage_base_sha}" \ + --artifact-name-prefix llvm-source-coverage-baseline- \ + --token-env GITHUB_TOKEN \ + --output-dir "${COVERAGE_BASELINE_DIR}" + baseline_fetch_status=$? + set -e + case "${baseline_fetch_status}" in + 0) + baseline_mount=/tmp/llvm-coverage-baseline + docker_args+=(--volume="${COVERAGE_BASELINE_DIR}:${baseline_mount}:ro") + script_args+=(--coverage-baseline-summary "${baseline_mount}/coverage-summary.json") + ;; + 3) + echo "No trusted exact-base coverage artifact is available; PR patch coverage will still be reported." + ;; + *) + echo "::warning::Coverage baseline lookup failed with status ${baseline_fetch_status}; PR patch coverage will continue without an aggregate delta." + ;; + esac + fi + fi + if [ ! -z "${{ inputs.GEOS_ENABLE_BOUNDS_CHECK }}" ]; then script_args+=(--geos-enable-bounds-check "${{ inputs.GEOS_ENABLE_BOUNDS_CHECK }}") fi @@ -291,7 +385,7 @@ jobs: script_args+=(${{ inputs.BUILD_AND_TEST_CLI_ARGS }}) - DOCKER_REPOSITORY=${{ inputs.DOCKER_REPOSITORY }} + DOCKER_REPOSITORY=${CI_DOCKER_REPOSITORY} SPLIT_DOCKER_REPOSITORY=(${DOCKER_REPOSITORY//// }) CONTAINER_NAME=geosx_build_${SPLIT_DOCKER_REPOSITORY[1]}_${GITHUB_SHA:0:7}_${{ inputs.CMAKE_BUILD_TYPE }} echo "CONTAINER_NAME: ${CONTAINER_NAME}" @@ -305,10 +399,6 @@ jobs: script_args+=(--ctest-parallel-level "${{ inputs.CTEST_PARALLEL_LEVEL }}") fi - if ${{ inputs.CODE_COVERAGE }} == 'true'; then - script_args+=(--code-coverage) - fi - if [[ -n "${{ inputs.LOCAL_BASELINE_DIR }}" ]]; then # Extract the 'baseline' value @@ -353,7 +443,7 @@ jobs: --user nobody \ ${docker_args[@]} \ -h=`hostname` \ - ${{ inputs.DOCKER_REPOSITORY }}:${{ inputs.DOCKER_IMAGE_TAG }} \ + ${DOCKER_RUN_IMAGE} \ ${GITHUB_WORKSPACE_MOUNT_POINT}/scripts/ci_build_and_test_in_container.sh \ ${script_args[@]}" @@ -365,7 +455,7 @@ jobs: docker run \ ${docker_args[@]} \ -h=`hostname` \ - ${{ inputs.DOCKER_REPOSITORY }}:${{ inputs.DOCKER_IMAGE_TAG }} \ + "${DOCKER_RUN_IMAGE}" \ ${GITHUB_WORKSPACE_MOUNT_POINT}/scripts/ci_build_and_test_in_container.sh \ ${script_args[@]} EXIT_STATUS=$? @@ -473,34 +563,160 @@ jobs: echo "GCP upload disabled." fi - - name: Prepare pip for Codecov - if: inputs.CODE_COVERAGE + if [[ "${CI_LLVM_SOURCE_COVERAGE}" == 'true' && + -n "${COVERAGE_BASELINE_DIR:-}" && + -f "${COVERAGE_BASELINE_DIR}/coverage-baseline-status.json" && + ! -L "${COVERAGE_BASELINE_DIR}/coverage-baseline-status.json" ]]; then + cp -- "${COVERAGE_BASELINE_DIR}/coverage-baseline-status.json" \ + "${COVERAGE_ARTIFACT_DIR}/coverage-baseline-status.json" + fi + + if [[ "${CI_LLVM_SOURCE_COVERAGE}" == 'true' && + -n "${COVERAGE_ARTIFACT_DIR:-}" && + -d "${COVERAGE_ARTIFACT_DIR}" && + ! -L "${COVERAGE_ARTIFACT_DIR}" ]]; then + if ! python3 scripts/render_coverage_report_html.py \ + --artifact-dir "${COVERAGE_ARTIFACT_DIR}" \ + --output "${COVERAGE_ARTIFACT_DIR}/index.html"; then + echo "LLVM source coverage HTML report could not be finalized." >&2 + if [[ ${EXIT_STATUS} -eq 0 ]]; then + EXIT_STATUS=1 + fi + fi + fi + + exit ${EXIT_STATUS} + + - name: Validate LLVM coverage HTML report + id: validate_llvm_coverage_artifact + if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE }} run: | - set -euxo pipefail - python3 --version - python3 -m ensurepip --upgrade || true - python3 -m pip --version - mkdir -p "${RUNNER_TEMP}/codecov-bin" - cat > "${RUNNER_TEMP}/codecov-bin/pip" <<'EOF' - #!/usr/bin/env bash - exec python3 -m pip "$@" - EOF - chmod +x "${RUNNER_TEMP}/codecov-bin/pip" - echo "${RUNNER_TEMP}/codecov-bin" >> "${GITHUB_PATH}" - - - name: Upload coverage to Codecov - if: inputs.CODE_COVERAGE - uses: codecov/codecov-action@v7.0.0 - with: - files: ${GITHUB_WORKSPACE}/geos_coverage.info.cleaned - fail_ci_if_error: true - use_pypi: true - use_legacy_upload_endpoint: true + if [[ -z "${LLVM_COVERAGE_ARTIFACT_DIR:-}" || + ! -d "${LLVM_COVERAGE_ARTIFACT_DIR}" || + -L "${LLVM_COVERAGE_ARTIFACT_DIR}" ]]; then + echo "LLVM coverage artifact directory is missing or unsafe." >&2 + exit 1 + fi + if [[ ! -f "${LLVM_COVERAGE_ARTIFACT_DIR}/index.html" || + -L "${LLVM_COVERAGE_ARTIFACT_DIR}/index.html" ]]; then + echo "LLVM coverage HTML report is missing or unsafe." >&2 + exit 1 + fi + unsafe_link=$(find "${LLVM_COVERAGE_ARTIFACT_DIR}" -type l -print -quit) + if [[ -n "${unsafe_link}" ]]; then + echo "LLVM coverage artifact contains a symbolic link: ${unsafe_link}" >&2 + exit 1 + fi + unexpected_path=$(find "${LLVM_COVERAGE_ARTIFACT_DIR}" -mindepth 1 ! -type f -print -quit) + if [[ -n "${unexpected_path}" ]]; then + echo "LLVM coverage artifact contains a non-file entry: ${unexpected_path}" >&2 + exit 1 + fi + while IFS= read -r artifact_file; do + case "$(basename -- "${artifact_file}")" in + CMakeCache.txt|branch-summary.txt|coverage-baseline-status.json|coverage-objects.txt|coverage-status.md|coverage-summary.json|coverage-summary.md|ctest.log|index.html|llvm-summary.json|mapping-integrity.log|native-export.log|pr-coverage.json|pr-coverage.md|summary-export.log|toolchain.txt) ;; + *) + echo "Unexpected LLVM coverage artifact: ${artifact_file}" >&2 + exit 1 + ;; + esac + done < <(find "${LLVM_COVERAGE_ARTIFACT_DIR}" -mindepth 1 -maxdepth 1 -type f -print) + artifact_bytes=$(find "${LLVM_COVERAGE_ARTIFACT_DIR}" -type f -printf '%s\n' \ + | awk '{ total += $1 } END { print total + 0 }') + if (( artifact_bytes > 50 * 1024 * 1024 )); then + echo "LLVM coverage artifact is too large: ${artifact_bytes} bytes" >&2 + exit 1 + fi + + - name: Publish LLVM coverage summary + if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE }} env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - SSL_CERT_FILE: /etc/pki/tls/certs/ca-bundle.crt - REQUESTS_CA_BUNDLE: /etc/pki/tls/certs/ca-bundle.crt - CURL_CA_BUNDLE: /etc/pki/tls/certs/ca-bundle.crt + CI_COVERAGE_ARTIFACT_VALIDATION: ${{ steps.validate_llvm_coverage_artifact.outcome }} + CI_COVERAGE_BASE_SHA: ${{ inputs.COVERAGE_BASE_SHA }} + run: | + printf '## LLVM source coverage\n\n' >> "${GITHUB_STEP_SUMMARY}" + if [[ "${CI_COVERAGE_ARTIFACT_VALIDATION}" != success ]]; then + { + printf '### ❌ Coverage results unavailable\n\n' + printf '| Validation | Result | Details |\n' + printf '|---|:---:|---|\n' + printf '| HTML report artifact | ❌ Failed | Artifact validation failed; inspect the job log |\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + status_report="${LLVM_COVERAGE_ARTIFACT_DIR}/coverage-status.md" + metrics_report="${LLVM_COVERAGE_ARTIFACT_DIR}/coverage-summary.md" + if [[ -f "${status_report}" && ! -L "${status_report}" ]]; then + cat "${status_report}" >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '### ❌ Coverage results unavailable\n\n' + printf '| Validation | Result | Details |\n' + printf '|---|:---:|---|\n' + printf '| Build, test, and report | ❌ Failed | No status report was produced; inspect the job log |\n\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + if [[ -f "${metrics_report}" && ! -L "${metrics_report}" ]]; then + cat "${metrics_report}" >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '### Coverage metrics unavailable\n\n' + printf 'The job did not produce a validated coverage report. Inspect the build and report logs.\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + if [[ -n "${CI_COVERAGE_BASE_SHA}" ]]; then + pr_report="${LLVM_COVERAGE_ARTIFACT_DIR}/pr-coverage.md" + if [[ -f "${pr_report}" && ! -L "${pr_report}" ]]; then + printf '\n' >> "${GITHUB_STEP_SUMMARY}" + cat "${pr_report}" >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '\n### PR coverage unavailable\n\n' + printf 'The patch-specific report was not produced; inspect the job log.\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + - name: Retain LLVM coverage HTML report + if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE && steps.validate_llvm_coverage_artifact.outcome == 'success' }} + uses: actions/upload-artifact@v4.6.2 + with: + name: llvm-source-coverage-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/index.html + if-no-files-found: warn + retention-days: 7 + + - name: Retain trusted develop coverage baseline + if: ${{ success() && inputs.LLVM_SOURCE_COVERAGE && github.event_name == 'push' && github.ref == 'refs/heads/develop' && steps.validate_llvm_coverage_artifact.outcome == 'success' }} + uses: actions/upload-artifact@v4.6.2 + with: + name: llvm-source-coverage-baseline-${{ github.sha }} + path: | + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-summary.json + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/llvm-summary.json + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/toolchain.txt + if-no-files-found: error + overwrite: true + retention-days: 90 + + - name: Remove temporary LLVM coverage files + if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE }} + run: | + if [[ -n "${LLVM_COVERAGE_ARTIFACT_DIR:-}" && + -d "${LLVM_COVERAGE_ARTIFACT_DIR}" && + ! -L "${LLVM_COVERAGE_ARTIFACT_DIR}" ]]; then + docker run --rm \ + --volume="${LLVM_COVERAGE_ARTIFACT_DIR}:/tmp/coverage-artifact" \ + alpine:3.20 \ + sh -lc 'find /tmp/coverage-artifact -mindepth 1 -delete' + rmdir "${LLVM_COVERAGE_ARTIFACT_DIR}" + fi + if [[ -n "${LLVM_COVERAGE_BASELINE_DIR:-}" && + -d "${LLVM_COVERAGE_BASELINE_DIR}" && + ! -L "${LLVM_COVERAGE_BASELINE_DIR}" ]]; then + find "${LLVM_COVERAGE_BASELINE_DIR}" -mindepth 1 -maxdepth 1 -type f -delete + rmdir "${LLVM_COVERAGE_BASELINE_DIR}" + fi - name: Show sccache summary if: ${{ always() && inputs.USE_SCCACHE }} diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 27d9f1f9cdc..e387304cd43 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -13,6 +13,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + # Please define `build.args.GEOS_TPL_TAG` in `.devcontainer/devcontainer.json` jobs: @@ -59,7 +63,13 @@ jobs: - name: Extract docker image tag id: extract_docker_image_tag run: | - echo "DOCKER_IMAGE_TAG=$(jq '.build.args.GEOS_TPL_TAG' -r .devcontainer/devcontainer.json)" >> "$GITHUB_OUTPUT" + docker_image_tag=$(jq -er '.build.args.GEOS_TPL_TAG | select(type == "string")' \ + .devcontainer/devcontainer.json) + if [[ ! "${docker_image_tag}" =~ ^[0-9]+-[0-9]+$ ]]; then + echo "Invalid GEOS_TPL_TAG: ${docker_image_tag}" >&2 + exit 1 + fi + printf 'DOCKER_IMAGE_TAG=%s\n' "${docker_image_tag}" >> "${GITHUB_OUTPUT}" # PR must be assigned to be merged. # This job will fail if this is not the case. @@ -301,26 +311,36 @@ jobs: - name: Check that the baseline logs are modified if rebaselines are detected run: "scripts/check_baseline_log.sh" - # code_coverage: - # needs: - # - is_not_draft_pull_request - # uses: ./.github/workflows/build_and_test.yml - # secrets: - # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # GOOGLE_CLOUD_GCP: ${{ secrets.GOOGLE_CLOUD_GCP }} - # with: - # BUILD_AND_TEST_CLI_ARGS: "--no-run-unit-tests" - # CMAKE_BUILD_TYPE: Debug - # CODE_COVERAGE: true - # DOCKER_IMAGE_TAG: ${{ needs.is_not_draft_pull_request.outputs.DOCKER_IMAGE_TAG }} - # DOCKER_REPOSITORY: geosx/ubuntu24.04-gcc13 - # ENABLE_HYPRE: ON - # ENABLE_TRILINOS: OFF - # RUNS_ON: streak2 - # REQUIRED_LABEL: "ci: run code coverage" - # HOST_CONFIG: /spack-generated.cmake - # NPROC: 4 - # DOCKER_RUN_ARGS: "--cpus=8 --memory=256g -v /etc/pki/tls/certs/ca-bundle.crt:/etc/pki/tls/certs/ca-bundle.crt:ro -v /etc/pki/tls/certs/ca-bundle.crt:/certs/ca-bundle.crt:ro" + llvm_source_coverage: + needs: + - is_not_draft_pull_request + permissions: + actions: read + contents: read + uses: ./.github/workflows/build_and_test.yml + with: + BUILD_AND_TEST_CLI_ARGS: "--build-exe-only" + BUILD_SHARED_LIBS: "ON" + CMAKE_BUILD_TYPE: RelWithDebInfo + COVERAGE_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + COVERAGE_HEAD_SHA: ${{ github.sha }} + # Each instrumented process flushes a sizeable LLVM profile. Keeping 12 + # CTest slots avoids turning the short smoke-test phase into an I/O race. + CTEST_PARALLEL_LEVEL: "12" + DOCKER_IMAGE_TAG: ${{ needs.is_not_draft_pull_request.outputs.DOCKER_IMAGE_TAG }} + DOCKER_REPOSITORY: geosx/ubuntu24.04-clang20 + DOCKER_RUN_ARGS: "--cpus=32 --memory=256g -v /etc/pki/tls/certs/ca-bundle.crt:/etc/pki/tls/certs/ca-bundle.crt:ro -v /etc/pki/tls/certs/ca-bundle.crt:/certs/ca-bundle.crt:ro" + ENABLE_HYPRE: "ON" + ENABLE_TRILINOS: "OFF" + GEOS_ENABLE_BOUNDS_CHECK: "OFF" + HOST_CONFIG: /spack-generated.cmake + LLVM_SOURCE_COVERAGE: true + REQUIRED_LABEL: "ci: run code coverage" + # Instrumented compilation dominates runtime, so reserve the dedicated + # 32-core lane while leaving the profile-heavy test concurrency bounded. + NPROC: "32" + RUNS_ON: streak2-32core + USE_SCCACHE: false # mac_builds: @@ -446,6 +466,7 @@ jobs: - check_code_rules - cpu_builds - cuda_builds + - llvm_source_coverage - run_integrated_tests if: ${{ always() }} steps: @@ -455,12 +476,14 @@ jobs: echo "check_code_style_and_documentation: ${{needs.check_code_style_and_documentation.result}}" echo "cpu_builds: ${{needs.cpu_builds.result}}" echo "cuda_builds: ${{needs.cuda_builds.result}}" + echo "llvm_source_coverage: ${{needs.llvm_source_coverage.result}}" echo "run_integrated_tests: ${{needs.run_integrated_tests.result}} " ${{ needs.if_not_unassigned_pull_request.result == 'success' && needs.are_submodules_in_sync.result == 'success' && needs.check_code_style_and_documentation.result == 'success' && needs.cpu_builds.result == 'success' && - needs.cuda_builds.result == 'success' && + needs.cuda_builds.result == 'success' && + needs.llvm_source_coverage.result == 'success' && needs.run_integrated_tests.result == 'success' }} diff --git a/README.md b/README.md index 6d4cfee25ff..d16a4167be9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ [![DOI](https://zenodo.org/badge/131810578.svg)](https://zenodo.org/badge/latestdoi/131810578) -[![codecov](https://codecov.io/github/GEOS-DEV/GEOS/graph/badge.svg?token=0VTEHPQG58)](https://codecov.io/github/GEOS-DEV/GEOS) [![CI](https://github.com/GEOS-DEV/GEOS/actions/workflows/ci_tests.yml/badge.svg)](https://github.com/GEOS-DEV/GEOS/actions?query=branch%3Adevelop) [![docs](https://readthedocs.com/projects/geosx-geosx/badge/?version=latest)](https://geosx-geosx.readthedocs-hosted.com/en/latest/) diff --git a/inputFiles/coverageSmokes/ALM_inclinedFault_small_mesh.xml b/inputFiles/coverageSmokes/ALM_inclinedFault_small_mesh.xml new file mode 100644 index 00000000000..0ddc5c89ce6 --- /dev/null +++ b/inputFiles/coverageSmokes/ALM_inclinedFault_small_mesh.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inputFiles/coverageSmokes/ContactMechanics_SimpleCubes_small_mesh.xml b/inputFiles/coverageSmokes/ContactMechanics_SimpleCubes_small_mesh.xml new file mode 100644 index 00000000000..8afbe912908 --- /dev/null +++ b/inputFiles/coverageSmokes/ContactMechanics_SimpleCubes_small_mesh.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/inputFiles/coverageSmokes/FieldCaseTutorial3_Isothermal_small_mesh.xml b/inputFiles/coverageSmokes/FieldCaseTutorial3_Isothermal_small_mesh.xml new file mode 100644 index 00000000000..5fe85c30c3b --- /dev/null +++ b/inputFiles/coverageSmokes/FieldCaseTutorial3_Isothermal_small_mesh.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/FieldCaseTutorial3_Thermal_small_mesh.xml b/inputFiles/coverageSmokes/FieldCaseTutorial3_Thermal_small_mesh.xml new file mode 100644 index 00000000000..70520b5d874 --- /dev/null +++ b/inputFiles/coverageSmokes/FieldCaseTutorial3_Thermal_small_mesh.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/FlowProppantBedTransport2d_fast_outputs.xml b/inputFiles/coverageSmokes/FlowProppantBedTransport2d_fast_outputs.xml new file mode 100644 index 00000000000..2ad6918b144 --- /dev/null +++ b/inputFiles/coverageSmokes/FlowProppantBedTransport2d_fast_outputs.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/LagrangeContactBubbleStab_small_mesh.xml b/inputFiles/coverageSmokes/LagrangeContactBubbleStab_small_mesh.xml new file mode 100644 index 00000000000..ab01ad63878 --- /dev/null +++ b/inputFiles/coverageSmokes/LagrangeContactBubbleStab_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/PhaseFieldFracture_Nucleation_fast_events.xml b/inputFiles/coverageSmokes/PhaseFieldFracture_Nucleation_fast_events.xml new file mode 100644 index 00000000000..cd3a3c807bc --- /dev/null +++ b/inputFiles/coverageSmokes/PhaseFieldFracture_Nucleation_fast_events.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/inputFiles/coverageSmokes/PoroElastic_staircase_fim_small_mesh.xml b/inputFiles/coverageSmokes/PoroElastic_staircase_fim_small_mesh.xml new file mode 100644 index 00000000000..68ad874ddfa --- /dev/null +++ b/inputFiles/coverageSmokes/PoroElastic_staircase_fim_small_mesh.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/SeismicityRate_small_mesh.xml b/inputFiles/coverageSmokes/SeismicityRate_small_mesh.xml new file mode 100644 index 00000000000..a4f9bded257 --- /dev/null +++ b/inputFiles/coverageSmokes/SeismicityRate_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/SpringSliderImplicit_fast_events.xml b/inputFiles/coverageSmokes/SpringSliderImplicit_fast_events.xml new file mode 100644 index 00000000000..8bac2c514e0 --- /dev/null +++ b/inputFiles/coverageSmokes/SpringSliderImplicit_fast_events.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/inputFiles/coverageSmokes/TFrac_small_mesh.xml b/inputFiles/coverageSmokes/TFrac_small_mesh.xml new file mode 100644 index 00000000000..07cd2792f37 --- /dev/null +++ b/inputFiles/coverageSmokes/TFrac_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/ThermoPoroDruckerPrager_fast_events.xml b/inputFiles/coverageSmokes/ThermoPoroDruckerPrager_fast_events.xml new file mode 100644 index 00000000000..dd9f308d29f --- /dev/null +++ b/inputFiles/coverageSmokes/ThermoPoroDruckerPrager_fast_events.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/inputFiles/coverageSmokes/ThermoPoroElastic_staircase_small_mesh.xml b/inputFiles/coverageSmokes/ThermoPoroElastic_staircase_small_mesh.xml new file mode 100644 index 00000000000..7d5360a484f --- /dev/null +++ b/inputFiles/coverageSmokes/ThermoPoroElastic_staircase_small_mesh.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/inputFiles/coverageSmokes/acouselas3D_Q2_small_mesh.xml b/inputFiles/coverageSmokes/acouselas3D_Q2_small_mesh.xml new file mode 100644 index 00000000000..ddbc662b11e --- /dev/null +++ b/inputFiles/coverageSmokes/acouselas3D_Q2_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/co2_flux_3d_small_mesh.xml b/inputFiles/coverageSmokes/co2_flux_3d_small_mesh.xml new file mode 100644 index 00000000000..c2c11cb066d --- /dev/null +++ b/inputFiles/coverageSmokes/co2_flux_3d_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/co2_flux_dirichlet_small_mesh.xml b/inputFiles/coverageSmokes/co2_flux_dirichlet_small_mesh.xml new file mode 100644 index 00000000000..928f8df3a33 --- /dev/null +++ b/inputFiles/coverageSmokes/co2_flux_dirichlet_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/co2_thermal_obl_small_mesh.xml b/inputFiles/coverageSmokes/co2_thermal_obl_small_mesh.xml new file mode 100644 index 00000000000..999234923e1 --- /dev/null +++ b/inputFiles/coverageSmokes/co2_thermal_obl_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/deadOilEgg_small_mesh.xml b/inputFiles/coverageSmokes/deadOilEgg_small_mesh.xml new file mode 100644 index 00000000000..a896c6a8093 --- /dev/null +++ b/inputFiles/coverageSmokes/deadOilEgg_small_mesh.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/inputFiles/coverageSmokes/deadOil_edfm_small_mesh.xml b/inputFiles/coverageSmokes/deadOil_edfm_small_mesh.xml new file mode 100644 index 00000000000..27d9f54e31c --- /dev/null +++ b/inputFiles/coverageSmokes/deadOil_edfm_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/fault_contact_coverage.vtu b/inputFiles/coverageSmokes/fault_contact_coverage.vtu new file mode 100644 index 00000000000..d2b23917110 --- /dev/null +++ b/inputFiles/coverageSmokes/fault_contact_coverage.vtu @@ -0,0 +1,1495 @@ + + + + + + + 1 12 14 15 16 17 + 63 64 119 147 475 476 + 477 478 479 480 481 482 + 483 484 485 486 487 488 + 489 490 491 492 493 494 + 495 496 497 498 499 500 + 501 502 503 531 532 560 + 588 589 590 591 592 593 + 594 595 596 597 598 599 + 600 601 602 603 604 605 + 606 607 608 609 610 611 + 612 613 614 615 616 617 + 618 619 620 621 622 623 + 624 625 626 627 628 629 + 630 631 632 633 634 635 + 636 637 638 639 640 641 + 642 643 644 645 646 647 + 648 649 650 651 652 653 + 654 655 656 657 658 659 + 660 661 662 663 664 665 + 666 667 668 669 670 671 + 672 673 674 675 676 677 + 678 679 680 681 682 683 + 684 685 686 687 688 689 + 690 691 692 693 694 695 + 696 697 698 699 700 701 + 702 703 704 705 706 707 + 708 709 737 738 766 794 + 823 852 938 1024 1130 1245 + 1653 1681 1709 1737 1765 1793 + 1821 1849 1877 1905 1933 1961 + 1989 2017 2045 2073 2101 2129 + 2157 2185 2213 2241 2269 2297 + 2325 2353 2381 2409 2437 2465 + 2493 2521 2549 2577 2605 2633 + 2661 2689 2717 2745 2773 2801 + 2829 2857 2885 2913 2941 2969 + 2997 3025 3053 3081 3109 3137 + 3165 3193 3221 3249 3277 3305 + 3333 3361 3389 3417 3445 3473 + 3501 3529 3557 3585 3613 3641 + 3669 3697 3725 3753 3781 3809 + 3837 3865 3893 3921 3949 3977 + 4005 4033 4061 4089 4117 4145 + 4173 4201 4229 4257 4285 4313 + 4341 4369 4397 4425 4453 4481 + 4509 4537 4565 4593 4621 4649 + 4677 4705 4733 4761 4789 4817 + 4845 4873 4901 4929 4957 4985 + 5013 5041 5069 5097 5125 5153 + 5181 5209 5237 5265 5293 5321 + 5349 5377 5405 5433 5461 5489 + 5517 5545 5573 5601 5629 5657 + 5685 5713 5741 5769 5797 5825 + 5826 5854 5882 5910 5938 5966 + 5994 6022 6050 6078 6106 6134 + 6162 6190 6218 6246 6274 6302 + 6330 6358 6386 6414 6442 6470 + 6498 6526 6554 6582 6637 6665 + 6693 6721 6749 6777 6805 6833 + 6861 6889 6917 6945 6973 7001 + 7029 7057 7085 7113 7141 7169 + 7197 7225 7253 7281 7309 7337 + 7365 7393 7394 7422 7450 7478 + 7506 7534 7562 7590 7618 7646 + 7674 7702 7730 7758 7786 7814 + 7842 7870 7898 7926 7954 7982 + 8010 8038 8066 8094 8122 8150 + 8178 8206 8234 8262 8290 8318 + 8346 8374 8402 8430 8458 8486 + 8514 8542 8570 8598 8626 8654 + 8682 8710 8738 8766 8794 8822 + 8850 8878 8906 8934 8962 8990 + 9018 9046 9074 9102 9130 9158 + 9186 9214 9242 9270 9298 9326 + 9354 9382 9410 9438 9466 9494 + 9522 9550 9578 9606 9634 9662 + 9690 9718 9746 9774 9802 9830 + 9858 9886 9914 9942 9970 9998 + 10053 10054 10055 10056 10057 10058 + 10059 10060 10061 10062 10063 10064 + 10065 10066 10067 10068 10069 10070 + 10071 10072 10073 10074 10075 10076 + 10077 10078 10079 10080 10081 10109 + 10922 10923 10924 10925 10926 10927 + 10928 10929 10930 10931 10932 10933 + 10934 10935 10936 10937 10938 10939 + 10940 10941 10942 10943 10944 10945 + 10946 10947 10948 10949 10977 11790 + 11791 11792 11793 11794 11795 11796 + 11797 11798 11799 11800 11801 11802 + 11803 11804 11805 11806 11807 11808 + 11809 11810 11811 11812 11813 11814 + 11815 11816 11817 11818 11819 11820 + 11821 11822 11823 11824 11825 11826 + 11827 11855 12958 12959 12960 12961 + 12962 12963 12964 12965 12966 12967 + 12968 12969 12970 12971 12972 12973 + 12974 12975 12976 12977 12978 12979 + 12980 12981 12982 12983 12984 12985 + 13013 13881 13882 13883 13884 13885 + 13886 13887 13888 13889 13890 13891 + 13892 13893 13894 13895 13896 13897 + 13898 13899 13900 13901 13902 13903 + 13904 13905 13906 13907 13908 13909 + 14694 14750 15590 16402 17532 18372 + 19968 19969 19970 19971 19972 19973 + 19974 19975 19976 19977 19978 19979 + 19980 19981 19982 19983 19984 19985 + 19986 19987 19988 19989 19990 19991 + 19992 19993 19994 19995 20752 20753 + 20754 20755 20756 20757 20758 20759 + 20760 20761 20762 20763 20764 20765 + 20766 20767 20768 20769 20770 20771 + 20772 20773 20774 20775 20776 20777 + 20778 20779 21806 21807 21808 21809 + 21810 21811 21812 21813 21814 21815 + 21816 21817 21818 21819 21820 21821 + 21822 21823 21824 21825 21826 21827 + 21828 21829 21830 21831 21832 21833 + 21834 21835 21836 21837 21838 21839 + 21840 21841 21842 21843 22600 22601 + 22602 22603 22604 22605 22606 22607 + 22608 22609 22610 22611 22612 22613 + 22614 22615 22616 22617 22618 22619 + 22620 22621 22622 22623 22624 22625 + 22626 22627 23384 23385 23386 23387 + 23388 23389 23390 23391 23392 23393 + 23394 23395 23396 23397 23398 23399 + 23400 23401 23402 23403 23404 23405 + 23406 23407 23408 23409 23410 23411 + 23412 23413 23414 23415 23416 23417 + 23418 23419 23420 23421 23422 23423 + 23424 23425 23426 23427 23428 23429 + 23430 23431 23432 23433 23434 23435 + 23436 23437 23438 23439 24223 24251 + 24279 24307 24335 24363 24391 24419 + 24447 24475 24503 24531 24559 24587 + 24615 24643 24671 24699 24727 24755 + 24783 24811 24839 24867 24895 24923 + 24951 24979 24980 24981 24982 24983 + 24984 24985 24986 24987 24988 24989 + 24990 24991 24992 24993 24994 24995 + 24996 24997 24998 24999 25000 25001 + 25002 25003 25004 25005 25006 25007 + 25008 25009 25010 25011 25012 25013 + 25014 25015 25016 25017 26044 26045 + 26046 26047 26048 26049 26050 26051 + 26052 26053 26054 26055 26056 26057 + 26058 26059 26060 26061 26062 26063 + 26064 26065 26066 26067 26068 26069 + 26070 26071 26828 26829 26830 26831 + 26832 26833 26834 26835 26836 26837 + 26838 26839 26840 26841 26842 26843 + 26844 26845 26846 26847 26848 26849 + 26850 26851 26852 26853 26854 26855 + + + 1 1 1 1 1 1 + 0 0 0 0 0 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 0 0 0 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 0 0 0 0 + 1 1 1 1 1 1 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 0 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 0 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 0 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 0 0 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + 0 0 0 0 0 0 + + + + + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 96 96 96 96 96 + 96 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 94 94 + 94 94 94 94 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 97 97 97 97 97 97 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 95 95 + 95 95 95 95 + + + 1653 1654 1655 1656 1657 1658 + 1659 1660 1661 1662 1663 1664 + 1665 1666 1667 1668 1669 1670 + 1671 1672 1673 1674 1675 1676 + 1677 1678 1679 1680 1681 3335 + 3336 3337 3338 3339 3340 3341 + 3342 3343 3344 3345 3346 3347 + 3348 3349 3350 3351 3352 3353 + 3354 3355 3356 3357 3358 3359 + 3360 3361 3362 3363 5587 5588 + 5589 5590 5591 5592 5593 5594 + 5595 5596 5597 5598 5599 5600 + 5601 5602 5603 5604 5605 5606 + 5607 5608 5609 5610 5611 5612 + 5613 5614 5615 5616 5617 5618 + 5619 5620 5621 5622 5623 5624 + 5625 7279 7280 7281 7282 7283 + 7284 7285 7286 7287 7288 7289 + 7290 7291 7292 7293 7294 7295 + 7296 7297 7298 7299 7300 7301 + 7302 7303 7304 7305 7306 7307 + 8961 8962 8963 8964 8965 8966 + 8967 8968 8969 8970 8971 8972 + 8973 8974 8975 8976 8977 8978 + 8979 8980 8981 8982 8983 8984 + 8985 8986 8987 8988 8989 9831 + 9832 9833 9834 9835 9836 9837 + 9838 9839 9840 9841 9842 9843 + 9844 9845 9846 9847 9848 9849 + 9850 9851 9852 9853 9854 9855 + 9856 9857 9858 9859 10729 10787 + 10845 10903 10961 11019 11077 11135 + 11193 11251 11309 11367 11425 11483 + 11541 11599 11657 11715 11773 11831 + 11889 11947 12005 12063 12121 12179 + 12237 12295 12353 13485 13486 13487 + 13488 13489 13490 13491 13492 13493 + 13494 13495 13496 13497 13498 13499 + 13500 13501 13502 13503 13504 13505 + 13506 13507 13508 13509 13510 13511 + 13512 13513 13514 13515 13516 13517 + 13518 13519 13520 13521 13522 13523 + 15457 15458 15459 15460 15461 15462 + 15463 15464 15465 15466 15467 15468 + 15469 15470 15471 15472 15473 15474 + 15475 15476 15477 15478 15479 15480 + 15481 15482 15483 15484 15485 17139 + 17140 17141 17142 17143 17144 17145 + 17146 17147 17148 17149 17150 17151 + 17152 17153 17154 17155 17156 17157 + 17158 17159 17160 17161 17162 17163 + 17164 17165 17166 17167 + + + 1653 1654 1655 1656 1657 1658 + 1659 1660 1661 1662 1663 1664 + 1665 1666 1667 1668 1669 1670 + 1671 1672 1673 1674 1675 1676 + 1677 1678 1679 1680 1681 3335 + 3336 3337 3338 3339 3340 3341 + 3342 3343 3344 3345 3346 3347 + 3348 3349 3350 3351 3352 3353 + 3354 3355 3356 3357 3358 3359 + 3360 3361 3362 3363 5587 5588 + 5589 5590 5591 5592 5593 5594 + 5595 5596 5597 5598 5599 5600 + 5601 5602 5603 5604 5605 5606 + 5607 5608 5609 5610 5611 5612 + 5613 5614 5615 5616 5617 5618 + 5619 5620 5621 5622 5623 5624 + 5625 7279 7280 7281 7282 7283 + 7284 7285 7286 7287 7288 7289 + 7290 7291 7292 7293 7294 7295 + 7296 7297 7298 7299 7300 7301 + 7302 7303 7304 7305 7306 7307 + 8961 8962 8963 8964 8965 8966 + 8967 8968 8969 8970 8971 8972 + 8973 8974 8975 8976 8977 8978 + 8979 8980 8981 8982 8983 8984 + 8985 8986 8987 8988 8989 9831 + 9832 9833 9834 9835 9836 9837 + 9838 9839 9840 9841 9842 9843 + 9844 9845 9846 9847 9848 9849 + 9850 9851 9852 9853 9854 9855 + 9856 9857 9858 9859 10729 10787 + 10845 10903 10961 11019 11077 11135 + 11193 11251 11309 11367 11425 11483 + 11541 11599 11657 11715 11773 11831 + 11889 11947 12005 12063 12121 12179 + 12237 12295 12353 13485 13486 13487 + 13488 13489 13490 13491 13492 13493 + 13494 13495 13496 13497 13498 13499 + 13500 13501 13502 13503 13504 13505 + 13506 13507 13508 13509 13510 13511 + 13512 13513 13514 13515 13516 13517 + 13518 13519 13520 13521 13522 13523 + 15457 15458 15459 15460 15461 15462 + 15463 15464 15465 15466 15467 15468 + 15469 15470 15471 15472 15473 15474 + 15475 15476 15477 15478 15479 15480 + 15481 15482 15483 15484 15485 17139 + 17140 17141 17142 17143 17144 17145 + 17146 17147 17148 17149 17150 17151 + 17152 17153 17154 17155 17156 17157 + 17158 17159 17160 17161 17162 17163 + 17164 17165 17166 17167 + + + + + -288.7 -7.566675e-14 -3500 288.7 7.566675e-14 -2500 + 115.48 3.02667e-14 -2800 57.74 1.513335e-14 -2900 + -57.74 -1.513335e-14 -3100 -115.48 -3.02667e-14 -3200 + -276.1301 -7.720612e-14 -3500 -300.21375 -7.425672e-14 -3500 + 298.09875 7.451574e-14 -2500 273.30176 7.755249e-14 -2500 + 125.83009 2.899918e-14 -2800 282.7269 7.410123e-14 -2510.3447 + 276.75378 7.2535714e-14 -2520.6897 270.7807 7.0970194e-14 -2531.0344 + 264.8076 6.9404674e-14 -2541.3794 258.83447 6.783915e-14 -2551.724 + 252.86137 6.627363e-14 -2562.0688 246.88828 6.470812e-14 -2572.4138 + 240.91518 6.31426e-14 -2582.7585 234.94206 6.157708e-14 -2593.1035 + 228.96896 6.001156e-14 -2603.4482 222.99586 5.844604e-14 -2613.7932 + 217.02277 5.688052e-14 -2624.138 211.04965 5.5315002e-14 -2634.4827 + 205.07655 5.3749485e-14 -2644.8276 199.10345 5.2183965e-14 -2655.1724 + 193.13034 5.0618445e-14 -2665.5173 187.15724 4.9052928e-14 -2675.862 + 181.18414 4.7487408e-14 -2686.2068 175.21103 4.5921888e-14 -2696.5518 + 169.23793 4.435637e-14 -2706.8965 163.26483 4.279085e-14 -2717.2415 + 157.29172 4.1225334e-14 -2727.5862 151.31862 3.9659814e-14 -2737.9312 + 145.34552 3.8094294e-14 -2748.276 139.37242 3.6528777e-14 -2758.6206 + 133.3993 3.4963257e-14 -2768.9656 127.42621 3.3397737e-14 -2779.3103 + 121.4531 3.183222e-14 -2789.6553 68.40721 1.3826993e-14 -2900 + -70.80767 -1.3533022e-14 -3100 -128.1592 -2.8713946e-14 -3200 + 113.48897 2.974486e-14 -2803.4482 111.49793 2.922302e-14 -2806.8965 + 109.5069 2.870118e-14 -2810.3447 107.51586 2.8179341e-14 -2813.7932 + 105.524826 2.7657501e-14 -2817.2415 103.53379 2.7135661e-14 -2820.6897 + 101.542755 2.6613823e-14 -2824.138 99.55173 2.6091983e-14 -2827.5862 + 97.56069 2.5570143e-14 -2831.0344 95.56966 2.5048302e-14 -2834.4827 + 93.57862 2.4526464e-14 -2837.9312 91.587585 2.4004624e-14 -2841.3794 + 89.59655 2.3482784e-14 -2844.8276 87.605515 2.2960944e-14 -2848.276 + 85.61448 2.2439106e-14 -2851.724 83.62345 2.1917266e-14 -2855.1724 + 81.632416 2.1395425e-14 -2858.6206 79.64138 2.0873585e-14 -2862.0688 + 77.650345 2.0351747e-14 -2865.5173 75.65931 1.9829907e-14 -2868.9656 + 73.668274 1.9308067e-14 -2872.4138 71.67724 1.8786227e-14 -2875.862 + 69.68621 1.8264388e-14 -2879.3103 67.695175 1.7742548e-14 -2882.7585 + 65.70414 1.7220708e-14 -2886.2068 63.713104 1.6698868e-14 -2889.6553 + 61.72207 1.617703e-14 -2893.1035 59.731033 1.565519e-14 -2896.5518 + 54.778973 1.4357281e-14 -2905.1282 51.817947 1.3581211e-14 -2910.2563 + 48.856922 1.28051426e-14 -2915.3845 45.895897 1.2029073e-14 -2920.513 + 42.93487 1.1253004e-14 -2925.641 39.973846 1.04769344e-14 -2930.7693 + 37.01282 9.700865e-15 -2935.8975 34.051796 8.924796e-15 -2941.0256 + 31.090769 8.148727e-15 -2946.1538 28.129744 7.372658e-15 -2951.282 + 25.168718 6.5965884e-15 -2956.4102 22.207693 5.820519e-15 -2961.5386 + 19.246666 5.04445e-15 -2966.6667 16.28564 4.2683807e-15 -2971.795 + 13.3246155 3.4923115e-15 -2976.923 10.363589 2.7162424e-15 -2982.0513 + 7.402564 1.940173e-15 -2987.1794 4.4415383 1.1641038e-15 -2992.3076 + 1.4805129 3.880346e-16 -2997.4358 -1.4805129 -3.880346e-16 -3002.5642 + -4.4415383 -1.1641038e-15 -3007.6924 -7.402564 -1.940173e-15 -3012.8206 + -10.363589 -2.7162424e-15 -3017.9487 -13.3246155 -3.4923115e-15 -3023.077 + -16.28564 -4.2683807e-15 -3028.205 -19.246666 -5.04445e-15 -3033.3333 + -22.207693 -5.820519e-15 -3038.4614 -25.168718 -6.5965884e-15 -3043.5898 + -28.129744 -7.372658e-15 -3048.718 -31.090769 -8.148727e-15 -3053.8462 + -34.051796 -8.924796e-15 -3058.9744 -37.01282 -9.700865e-15 -3064.1025 + -39.973846 -1.04769344e-14 -3069.2307 -42.93487 -1.1253004e-14 -3074.359 + -45.895897 -1.2029073e-14 -3079.487 -48.856922 -1.28051426e-14 -3084.6155 + -51.817947 -1.3581211e-14 -3089.7437 -54.778973 -1.4357281e-14 -3094.8718 + -59.731033 -1.565519e-14 -3103.4482 -61.72207 -1.617703e-14 -3106.8965 + -63.713104 -1.6698868e-14 -3110.3447 -65.70414 -1.7220708e-14 -3113.7932 + -67.695175 -1.7742548e-14 -3117.2415 -69.68621 -1.8264388e-14 -3120.6897 + -71.67724 -1.8786227e-14 -3124.138 -73.668274 -1.9308067e-14 -3127.5862 + -75.65931 -1.9829907e-14 -3131.0344 -77.650345 -2.0351747e-14 -3134.4827 + -79.64138 -2.0873585e-14 -3137.9312 -81.632416 -2.1395425e-14 -3141.3794 + -83.62345 -2.1917266e-14 -3144.8276 -85.61448 -2.2439106e-14 -3148.276 + -87.605515 -2.2960944e-14 -3151.724 -89.59655 -2.3482784e-14 -3155.1724 + -91.587585 -2.4004624e-14 -3158.6206 -93.57862 -2.4526464e-14 -3162.0688 + -95.56966 -2.5048302e-14 -3165.5173 -97.56069 -2.5570143e-14 -3168.9656 + -99.55173 -2.6091983e-14 -3172.4138 -101.542755 -2.6613823e-14 -3175.862 + -103.53379 -2.7135661e-14 -3179.3103 -105.524826 -2.7657501e-14 -3182.7585 + -107.51586 -2.8179341e-14 -3186.2068 -109.5069 -2.870118e-14 -3189.6553 + -111.49793 -2.922302e-14 -3193.1035 -113.48897 -2.974486e-14 -3196.5518 + -121.4531 -3.183222e-14 -3210.3447 -127.42621 -3.3397737e-14 -3220.6897 + -133.3993 -3.4963257e-14 -3231.0344 -139.37242 -3.6528777e-14 -3241.3794 + -145.34552 -3.8094294e-14 -3251.724 -151.31862 -3.9659814e-14 -3262.0688 + -157.29172 -4.1225334e-14 -3272.4138 -163.26483 -4.279085e-14 -3282.7585 + -169.23793 -4.435637e-14 -3293.1035 -175.21103 -4.5921888e-14 -3303.4482 + -181.18414 -4.7487408e-14 -3313.7932 -187.15724 -4.9052928e-14 -3324.138 + -193.13034 -5.0618445e-14 -3334.4827 -199.10345 -5.2183965e-14 -3344.8276 + -205.07655 -5.3749485e-14 -3355.1724 -211.04965 -5.5315002e-14 -3365.5173 + -217.02277 -5.688052e-14 -3375.862 -222.99586 -5.844604e-14 -3386.2068 + -228.96896 -6.001156e-14 -3396.5518 -234.94206 -6.157708e-14 -3406.8965 + -240.91518 -6.31426e-14 -3417.2415 -246.88828 -6.470812e-14 -3427.5862 + -252.86137 -6.627363e-14 -3437.9312 -258.83447 -6.783915e-14 -3448.276 + -264.8076 -6.9404674e-14 -3458.6206 -270.7807 -7.0970194e-14 -3468.9656 + -276.75378 -7.2535714e-14 -3479.3103 -282.7269 -7.410123e-14 -3489.6553 + -46.438553 -1.6517378e-14 -3100 -103.8612 -3.1689593e-14 -3200 + 43.89537 1.6828829e-14 -2900 101.246895 3.200975e-14 -2800 + 288.7 1000 -2500 115.48 1000 -2800 + 57.74 1000 -2900 -57.74 1000 -3100 + -115.48 1000 -3200 -288.7 1000 -3500 + 131.77039 3.0568714e-14 -2789.6553 137.7107 3.2138253e-14 -2779.3103 + 143.65099 3.3707788e-14 -2768.9656 149.5913 3.5277323e-14 -2758.6206 + 155.53159 3.684686e-14 -2748.276 161.47188 3.8416397e-14 -2737.9312 + 167.41219 3.9985935e-14 -2727.5862 173.35248 4.155547e-14 -2717.2415 + 179.29279 4.3125005e-14 -2706.8965 185.23308 4.4694544e-14 -2696.5518 + 191.17337 4.626408e-14 -2686.2068 197.11368 4.7833617e-14 -2675.862 + 203.05397 4.9403153e-14 -2665.5173 208.99426 5.0972688e-14 -2655.1724 + 214.93457 5.2542226e-14 -2644.8276 220.87486 5.411176e-14 -2634.4827 + 226.81517 5.56813e-14 -2624.138 232.75546 5.7250835e-14 -2613.7932 + 238.69576 5.8820373e-14 -2603.4482 244.63606 6.0389905e-14 -2593.1035 + 250.57635 6.1959444e-14 -2582.7585 256.51666 6.352898e-14 -2572.4138 + 262.45694 6.5098514e-14 -2562.0688 268.39725 6.666805e-14 -2551.724 + 274.33755 6.823759e-14 -2541.3794 280.27786 6.980713e-14 -2531.0344 + 286.21814 7.137666e-14 -2520.6897 292.15845 7.29462e-14 -2510.3447 + 70.387314 1.4350171e-14 -2896.5518 72.36741 1.4873351e-14 -2893.1035 + 74.34751 1.539653e-14 -2889.6553 76.32761 1.5919708e-14 -2886.2068 + 78.30771 1.6442886e-14 -2882.7585 80.28781 1.6966067e-14 -2879.3103 + 82.26791 1.7489245e-14 -2875.862 84.24801 1.8012423e-14 -2872.4138 + 86.2281 1.8535602e-14 -2868.9656 88.208206 1.9058782e-14 -2865.5173 + 90.18831 1.958196e-14 -2862.0688 92.1684 2.0105138e-14 -2858.6206 + 94.148506 2.0628319e-14 -2855.1724 96.1286 2.1151497e-14 -2851.724 + 98.1087 2.1674675e-14 -2848.276 100.088806 2.2197854e-14 -2844.8276 + 102.0689 2.2721034e-14 -2841.3794 104.049 2.3244212e-14 -2837.9312 + 106.0291 2.376739e-14 -2834.4827 108.0092 2.4290569e-14 -2831.0344 + 109.989296 2.4813749e-14 -2827.5862 111.9694 2.5336927e-14 -2824.138 + 113.9495 2.5860106e-14 -2820.6897 115.929596 2.6383284e-14 -2817.2415 + 117.9097 2.6906464e-14 -2813.7932 119.88979 2.7429642e-14 -2810.3447 + 121.869896 2.795282e-14 -2806.8965 123.85 2.8476e-14 -2803.4482 + -43.49379 -1.5739317e-14 -3094.8718 -40.549026 -1.4961256e-14 -3089.7437 + -37.604263 -1.4183196e-14 -3084.6155 -34.6595 -1.3405135e-14 -3079.487 + -31.714737 -1.2627074e-14 -3074.359 -28.769974 -1.18490135e-14 -3069.2307 + -25.82521 -1.1070953e-14 -3064.1025 -22.880447 -1.0292892e-14 -3058.9744 + -19.935684 -9.514831e-15 -3053.8462 -16.990921 -8.7367705e-15 -3048.718 + -14.046157 -7.958709e-15 -3043.5898 -11.101394 -7.180648e-15 -3038.4614 + -8.1566305 -6.402588e-15 -3033.3333 -5.211868 -5.624527e-15 -3028.205 + -2.2671044 -4.846466e-15 -3023.077 0.6776588 -4.068405e-15 -3017.9487 + 3.622422 -3.2903444e-15 -3012.8206 6.5671854 -2.5122836e-15 -3007.6924 + 9.511949 -1.7342229e-15 -3002.5642 12.456712 -9.56162e-16 -2997.4358 + 15.401475 -1.7810126e-16 -2992.3076 18.346237 5.999595e-16 -2987.1794 + 21.291 1.3780203e-15 -2982.0513 24.235764 2.156081e-15 -2976.923 + 27.180527 2.934142e-15 -2971.795 30.12529 3.7122028e-15 -2966.6667 + 33.070053 4.4902635e-15 -2961.5386 36.014816 5.2683243e-15 -2956.4102 + 38.95958 6.046385e-15 -2951.282 41.904343 6.8244458e-15 -2946.1538 + 44.849106 7.602507e-15 -2941.0256 47.79387 8.380568e-15 -2935.8975 + 50.738632 9.1586284e-15 -2930.7693 53.683395 9.936689e-15 -2925.641 + 56.62816 1.071475e-14 -2920.513 59.57292 1.1492811e-14 -2915.3845 + 62.517685 1.22708714e-14 -2910.2563 65.46245 1.3048932e-14 -2905.1282 + -101.8811 -3.116641e-14 -3196.5518 -99.90102 -3.0643233e-14 -3193.1035 + -97.92092 -3.0120055e-14 -3189.6553 -95.940834 -2.9596873e-14 -3186.2068 + -93.96074 -2.9073694e-14 -3182.7585 -91.98065 -2.8550513e-14 -3179.3103 + -90.00056 -2.8027334e-14 -3175.862 -88.02047 -2.7504154e-14 -3172.4138 + -86.040375 -2.6980974e-14 -3168.9656 -84.06029 -2.6457794e-14 -3165.5173 + -82.08019 -2.5934614e-14 -3162.0688 -80.100105 -2.5411436e-14 -3158.6206 + -78.12001 -2.4888256e-14 -3155.1724 -76.13992 -2.4365076e-14 -3151.724 + -74.15983 -2.3841895e-14 -3148.276 -72.17974 -2.3318715e-14 -3144.8276 + -70.199646 -2.2795535e-14 -3141.3794 -68.21956 -2.2272355e-14 -3137.9312 + -66.23946 -2.1749177e-14 -3134.4827 -64.25938 -2.1225997e-14 -3131.0344 + -62.27928 -2.0702817e-14 -3127.5862 -60.29919 -2.0179637e-14 -3124.138 + -58.3191 -1.9656457e-14 -3120.6897 -56.33901 -1.9133277e-14 -3117.2415 + -54.358917 -1.8610097e-14 -3113.7932 -52.378826 -1.8086918e-14 -3110.3447 + -50.398735 -1.7563738e-14 -3106.8965 -48.418644 -1.7040558e-14 -3103.4482 + -270.1898 -7.5636586e-14 -3489.6553 -264.24948 -7.406705e-14 -3479.3103 + -258.30917 -7.2497516e-14 -3468.9656 -252.36885 -7.092798e-14 -3458.6206 + -246.42854 -6.9358446e-14 -3448.276 -240.48825 -6.778891e-14 -3437.9312 + -234.54794 -6.6219376e-14 -3427.5862 -228.60764 -6.464984e-14 -3417.2415 + -222.66733 -6.30803e-14 -3406.8965 -216.72702 -6.151077e-14 -3396.5518 + -210.78671 -5.994123e-14 -3386.2068 -204.8464 -5.83717e-14 -3375.862 + -198.9061 -5.6802158e-14 -3365.5173 -192.96579 -5.5232623e-14 -3355.1724 + -187.02548 -5.3663088e-14 -3344.8276 -181.08517 -5.2093553e-14 -3334.4827 + -175.14487 -5.0524018e-14 -3324.138 -169.20457 -4.8954483e-14 -3313.7932 + -163.26427 -4.7384948e-14 -3303.4482 -157.32396 -4.5815412e-14 -3293.1035 + -151.38365 -4.4245877e-14 -3282.7585 -145.44334 -4.2676342e-14 -3272.4138 + -139.50304 -4.1106804e-14 -3262.0688 -133.56273 -3.953727e-14 -3251.724 + -127.62242 -3.7967733e-14 -3241.3794 -121.68211 -3.63982e-14 -3231.0344 + -115.74181 -3.4828663e-14 -3220.6897 -109.801506 -3.3259128e-14 -3210.3447 + -294.28085 -7.268628e-14 -3489.6553 -288.34793 -7.111584e-14 -3479.3103 + -282.415 -6.9545396e-14 -3468.9656 -276.4821 -6.7974956e-14 -3458.6206 + -270.54916 -6.640452e-14 -3448.276 -264.61627 -6.483408e-14 -3437.9312 + -258.68335 -6.326364e-14 -3427.5862 -252.75043 -6.16932e-14 -3417.2415 + -246.8175 -6.012276e-14 -3406.8965 -240.8846 -5.855232e-14 -3396.5518 + -234.95168 -5.698188e-14 -3386.2068 -229.01877 -5.5411435e-14 -3375.862 + -223.08585 -5.3840995e-14 -3365.5173 -217.15294 -5.2270556e-14 -3355.1724 + -211.22002 -5.0700113e-14 -3344.8276 -205.2871 -4.9129673e-14 -3334.4827 + -199.35419 -4.7559233e-14 -3324.138 -193.42126 -4.5988793e-14 -3313.7932 + -187.48836 -4.441835e-14 -3303.4482 -181.55544 -4.284791e-14 -3293.1035 + -175.62251 -4.127747e-14 -3282.7585 -169.6896 -3.970703e-14 -3272.4138 + -163.75668 -3.813659e-14 -3262.0688 -157.82378 -3.6566148e-14 -3251.724 + -151.89085 -3.499571e-14 -3241.3794 -145.95795 -3.342527e-14 -3231.0344 + -140.02502 -3.185483e-14 -3220.6897 -134.0921 -3.0284386e-14 -3210.3447 + -126.18156 -2.8190466e-14 -3196.5518 -124.20392 -2.7666986e-14 -3193.1035 + -122.22627 -2.7143506e-14 -3189.6553 -120.248634 -2.6620026e-14 -3186.2068 + -118.270996 -2.6096546e-14 -3182.7585 -116.29336 -2.5573065e-14 -3179.3103 + -114.31572 -2.5049585e-14 -3175.862 -112.33808 -2.4526105e-14 -3172.4138 + -110.36044 -2.4002625e-14 -3168.9656 -108.382805 -2.3479145e-14 -3165.5173 + -106.40517 -2.2955665e-14 -3162.0688 -104.42753 -2.2432185e-14 -3158.6206 + -102.44989 -2.1908704e-14 -3155.1724 -100.47225 -2.1385224e-14 -3151.724 + -98.49461 -2.0861744e-14 -3148.276 -96.516975 -2.0338264e-14 -3144.8276 + -94.53934 -1.9814784e-14 -3141.3794 -92.5617 -1.9291304e-14 -3137.9312 + -90.58406 -1.8767824e-14 -3134.4827 -88.60642 -1.8244343e-14 -3131.0344 + -86.62878 -1.7720863e-14 -3127.5862 -84.65114 -1.7197383e-14 -3124.138 + -82.6735 -1.6673903e-14 -3120.6897 -80.69586 -1.6150423e-14 -3117.2415 + -78.71822 -1.5626943e-14 -3113.7932 -76.740585 -1.5103461e-14 -3110.3447 + -74.76295 -1.4579982e-14 -3106.8965 -72.78531 -1.4056502e-14 -3103.4482 + -67.86657 -1.2754513e-14 -3094.8718 -64.92546 -1.1976004e-14 -3089.7437 + -61.98436 -1.1197495e-14 -3084.6155 -59.043255 -1.04189855e-14 -3079.487 + -56.102154 -9.640477e-15 -3074.359 -53.16105 -8.861968e-15 -3069.2307 + -50.219944 -8.083459e-15 -3064.1025 -47.278843 -7.30495e-15 -3058.9744 + -44.337738 -6.526441e-15 -3053.8462 -41.396633 -5.747932e-15 -3048.718 + -38.455532 -4.9694233e-15 -3043.5898 -35.514427 -4.190914e-15 -3038.4614 + -32.573322 -3.4124052e-15 -3033.3333 -29.63222 -2.6338962e-15 -3028.205 + -26.691116 -1.8553873e-15 -3023.077 -23.750013 -1.0768783e-15 -3017.9487 + -20.808908 -2.9836931e-16 -3012.8206 -17.867805 4.8013967e-16 -3007.6924 + -14.926702 1.2586486e-15 -3002.5642 -11.985599 2.0371576e-15 -2997.4358 + -9.044495 2.8156665e-15 -2992.3076 -6.103391 3.5941755e-15 -2987.1794 + -3.1622872 4.3726843e-15 -2982.0513 -0.22118369 5.1511936e-15 -2976.923 + 2.71992 5.9297024e-15 -2971.795 5.6610236 6.7082113e-15 -2966.6667 + 8.602127 7.48672e-15 -2961.5386 11.543231 8.265229e-15 -2956.4102 + 14.484335 9.043739e-15 -2951.282 17.425438 9.822247e-15 -2946.1538 + 20.36654 1.0600756e-14 -2941.0256 23.307646 1.1379265e-14 -2935.8975 + 26.248749 1.2157774e-14 -2930.7693 29.189852 1.2936283e-14 -2925.641 + 32.130955 1.3714792e-14 -2920.513 35.07206 1.4493301e-14 -2915.3845 + 38.013165 1.527181e-14 -2910.2563 40.954266 1.6050319e-14 -2905.1282 + 45.87301 1.7352308e-14 -2896.5518 47.850647 1.7875788e-14 -2893.1035 + 49.828285 1.8399268e-14 -2889.6553 51.805927 1.8922748e-14 -2886.2068 + 53.783566 1.9446228e-14 -2882.7585 55.761204 1.996971e-14 -2879.3103 + 57.738842 2.049319e-14 -2875.862 59.71648 2.101667e-14 -2872.4138 + 61.69412 2.154015e-14 -2868.9656 63.671757 2.206363e-14 -2865.5173 + 65.6494 2.258711e-14 -2862.0688 67.62704 2.311059e-14 -2858.6206 + 69.604675 2.363407e-14 -2855.1724 71.58231 2.415755e-14 -2851.724 + 73.55995 2.468103e-14 -2848.276 75.53759 2.520451e-14 -2844.8276 + 77.51523 2.572799e-14 -2841.3794 79.49287 2.625147e-14 -2837.9312 + 81.470505 2.677495e-14 -2834.4827 83.44814 2.7298432e-14 -2831.0344 + 85.42578 2.7821912e-14 -2827.5862 87.40342 2.8345392e-14 -2824.138 + 89.38106 2.886887e-14 -2820.6897 91.358696 2.939235e-14 -2817.2415 + 93.336334 2.991583e-14 -2813.7932 95.31397 3.043931e-14 -2810.3447 + 97.29162 3.096279e-14 -2806.8965 99.26926 3.148627e-14 -2803.4482 + 107.17982 3.358019e-14 -2789.6553 113.11275 3.515063e-14 -2779.3103 + 119.04567 3.672107e-14 -2768.9656 124.9786 3.829151e-14 -2758.6206 + 130.91153 3.986195e-14 -2748.276 136.84445 4.143239e-14 -2737.9312 + 142.77737 4.3002826e-14 -2727.5862 148.71031 4.4573266e-14 -2717.2415 + 154.64323 4.6143705e-14 -2706.8965 160.57616 4.7714145e-14 -2696.5518 + 166.50908 4.9284585e-14 -2686.2068 172.44202 5.0855025e-14 -2675.862 + 178.37494 5.242546e-14 -2665.5173 184.30786 5.39959e-14 -2655.1724 + 190.24078 5.556634e-14 -2644.8276 196.17372 5.713678e-14 -2634.4827 + 202.10664 5.8707217e-14 -2624.138 208.03957 6.0277656e-14 -2613.7932 + 213.9725 6.1848096e-14 -2603.4482 219.90543 6.3418536e-14 -2593.1035 + 225.83835 6.4988976e-14 -2582.7585 231.77127 6.6559415e-14 -2572.4138 + 237.70421 6.8129855e-14 -2562.0688 243.63713 6.9700295e-14 -2551.724 + 249.57005 7.1270735e-14 -2541.3794 255.50298 7.2841174e-14 -2531.0344 + 261.4359 7.4411614e-14 -2520.6897 267.36884 7.5982054e-14 -2510.3447 + 298.09875 1000 -2500 121.4531 1000 -2789.6553 + 127.42621 1000 -2779.3103 133.3993 1000 -2768.9656 + 139.37242 1000 -2758.6206 145.34552 1000 -2748.276 + 151.31862 1000 -2737.9312 157.29172 1000 -2727.5862 + 163.26483 1000 -2717.2415 169.23793 1000 -2706.8965 + 175.21103 1000 -2696.5518 181.18414 1000 -2686.2068 + 187.15724 1000 -2675.862 193.13034 1000 -2665.5173 + 199.10345 1000 -2655.1724 205.07655 1000 -2644.8276 + 211.04965 1000 -2634.4827 217.02277 1000 -2624.138 + 222.99586 1000 -2613.7932 228.96896 1000 -2603.4482 + 234.94206 1000 -2593.1035 240.91518 1000 -2582.7585 + 246.88828 1000 -2572.4138 252.86137 1000 -2562.0688 + 258.83447 1000 -2551.724 264.8076 1000 -2541.3794 + 270.7807 1000 -2531.0344 276.75378 1000 -2520.6897 + 282.7269 1000 -2510.3447 125.83009 1000 -2800 + 59.731033 1000 -2896.5518 61.72207 1000 -2893.1035 + 63.713104 1000 -2889.6553 65.70414 1000 -2886.2068 + 67.695175 1000 -2882.7585 69.68621 1000 -2879.3103 + 71.67724 1000 -2875.862 73.668274 1000 -2872.4138 + 75.65931 1000 -2868.9656 77.650345 1000 -2865.5173 + 79.64138 1000 -2862.0688 81.632416 1000 -2858.6206 + 83.62345 1000 -2855.1724 85.61448 1000 -2851.724 + 87.605515 1000 -2848.276 89.59655 1000 -2844.8276 + 91.587585 1000 -2841.3794 93.57862 1000 -2837.9312 + 95.56966 1000 -2834.4827 97.56069 1000 -2831.0344 + 99.55173 1000 -2827.5862 101.542755 1000 -2824.138 + 103.53379 1000 -2820.6897 105.524826 1000 -2817.2415 + 107.51586 1000 -2813.7932 109.5069 1000 -2810.3447 + 111.49793 1000 -2806.8965 113.48897 1000 -2803.4482 + 68.40721 1000 -2900 -54.778973 1000 -3094.8718 + -51.817947 1000 -3089.7437 -48.856922 1000 -3084.6155 + -45.895897 1000 -3079.487 -42.93487 1000 -3074.359 + -39.973846 1000 -3069.2307 -37.01282 1000 -3064.1025 + -34.051796 1000 -3058.9744 -31.090769 1000 -3053.8462 + -28.129744 1000 -3048.718 -25.168718 1000 -3043.5898 + -22.207693 1000 -3038.4614 -19.246666 1000 -3033.3333 + -16.28564 1000 -3028.205 -13.3246155 1000 -3023.077 + -10.363589 1000 -3017.9487 -7.402564 1000 -3012.8206 + -4.4415383 1000 -3007.6924 -1.4805129 1000 -3002.5642 + 1.4805129 1000 -2997.4358 4.4415383 1000 -2992.3076 + 7.402564 1000 -2987.1794 10.363589 1000 -2982.0513 + 13.3246155 1000 -2976.923 16.28564 1000 -2971.795 + 19.246666 1000 -2966.6667 22.207693 1000 -2961.5386 + 25.168718 1000 -2956.4102 28.129744 1000 -2951.282 + 31.090769 1000 -2946.1538 34.051796 1000 -2941.0256 + 37.01282 1000 -2935.8975 39.973846 1000 -2930.7693 + 42.93487 1000 -2925.641 45.895897 1000 -2920.513 + 48.856922 1000 -2915.3845 51.817947 1000 -2910.2563 + 54.778973 1000 -2905.1282 -46.438553 1000 -3100 + -113.48897 1000 -3196.5518 -111.49793 1000 -3193.1035 + -109.5069 1000 -3189.6553 -107.51586 1000 -3186.2068 + -105.524826 1000 -3182.7585 -103.53379 1000 -3179.3103 + -101.542755 1000 -3175.862 -99.55173 1000 -3172.4138 + -97.56069 1000 -3168.9656 -95.56966 1000 -3165.5173 + -93.57862 1000 -3162.0688 -91.587585 1000 -3158.6206 + -89.59655 1000 -3155.1724 -87.605515 1000 -3151.724 + -85.61448 1000 -3148.276 -83.62345 1000 -3144.8276 + -81.632416 1000 -3141.3794 -79.64138 1000 -3137.9312 + -77.650345 1000 -3134.4827 -75.65931 1000 -3131.0344 + -73.668274 1000 -3127.5862 -71.67724 1000 -3124.138 + -69.68621 1000 -3120.6897 -67.695175 1000 -3117.2415 + -65.70414 1000 -3113.7932 -63.713104 1000 -3110.3447 + -61.72207 1000 -3106.8965 -59.731033 1000 -3103.4482 + -103.8612 1000 -3200 -276.1301 1000 -3500 + -282.7269 1000 -3489.6553 -276.75378 1000 -3479.3103 + -270.7807 1000 -3468.9656 -264.8076 1000 -3458.6206 + -258.83447 1000 -3448.276 -252.86137 1000 -3437.9312 + -246.88828 1000 -3427.5862 -240.91518 1000 -3417.2415 + -234.94206 1000 -3406.8965 -228.96896 1000 -3396.5518 + -222.99586 1000 -3386.2068 -217.02277 1000 -3375.862 + -211.04965 1000 -3365.5173 -205.07655 1000 -3355.1724 + -199.10345 1000 -3344.8276 -193.13034 1000 -3334.4827 + -187.15724 1000 -3324.138 -181.18414 1000 -3313.7932 + -175.21103 1000 -3303.4482 -169.23793 1000 -3293.1035 + -163.26483 1000 -3282.7585 -157.29172 1000 -3272.4138 + -151.31862 1000 -3262.0688 -145.34552 1000 -3251.724 + -139.37242 1000 -3241.3794 -133.3993 1000 -3231.0344 + -127.42621 1000 -3220.6897 -121.4531 1000 -3210.3447 + -128.1592 1000 -3200 -300.21375 1000 -3500 + -70.80767 1000 -3100 43.89537 1000 -2900 + 101.246895 1000 -2800 273.30176 1000 -2500 + 292.15845 1000 -2510.3447 286.21814 1000 -2520.6897 + 280.27786 1000 -2531.0344 274.33755 1000 -2541.3794 + 268.39725 1000 -2551.724 262.45694 1000 -2562.0688 + 256.51666 1000 -2572.4138 250.57635 1000 -2582.7585 + 244.63606 1000 -2593.1035 238.69576 1000 -2603.4482 + 232.75546 1000 -2613.7932 226.81517 1000 -2624.138 + 220.87486 1000 -2634.4827 214.93457 1000 -2644.8276 + 208.99426 1000 -2655.1724 203.05397 1000 -2665.5173 + 197.11368 1000 -2675.862 191.17337 1000 -2686.2068 + 185.23308 1000 -2696.5518 179.29279 1000 -2706.8965 + 173.35248 1000 -2717.2415 167.41219 1000 -2727.5862 + 161.47188 1000 -2737.9312 155.53159 1000 -2748.276 + 149.5913 1000 -2758.6206 143.65099 1000 -2768.9656 + 137.7107 1000 -2779.3103 131.77039 1000 -2789.6553 + 123.85 1000 -2803.4482 121.869896 1000 -2806.8965 + 119.88979 1000 -2810.3447 117.9097 1000 -2813.7932 + 115.929596 1000 -2817.2415 113.9495 1000 -2820.6897 + 111.9694 1000 -2824.138 109.989296 1000 -2827.5862 + 108.0092 1000 -2831.0344 106.0291 1000 -2834.4827 + 104.049 1000 -2837.9312 102.0689 1000 -2841.3794 + 100.088806 1000 -2844.8276 98.1087 1000 -2848.276 + 96.1286 1000 -2851.724 94.148506 1000 -2855.1724 + 92.1684 1000 -2858.6206 90.18831 1000 -2862.0688 + 88.208206 1000 -2865.5173 86.2281 1000 -2868.9656 + 84.24801 1000 -2872.4138 82.26791 1000 -2875.862 + 80.28781 1000 -2879.3103 78.30771 1000 -2882.7585 + 76.32761 1000 -2886.2068 74.34751 1000 -2889.6553 + 72.36741 1000 -2893.1035 70.387314 1000 -2896.5518 + 65.46245 1000 -2905.1282 62.517685 1000 -2910.2563 + 59.57292 1000 -2915.3845 56.62816 1000 -2920.513 + 53.683395 1000 -2925.641 50.738632 1000 -2930.7693 + 47.79387 1000 -2935.8975 44.849106 1000 -2941.0256 + 41.904343 1000 -2946.1538 38.95958 1000 -2951.282 + 36.014816 1000 -2956.4102 33.070053 1000 -2961.5386 + 30.12529 1000 -2966.6667 27.180527 1000 -2971.795 + 24.235764 1000 -2976.923 21.291 1000 -2982.0513 + 18.346237 1000 -2987.1794 15.401475 1000 -2992.3076 + 12.456712 1000 -2997.4358 9.511949 1000 -3002.5642 + 6.5671854 1000 -3007.6924 3.622422 1000 -3012.8206 + 0.6776588 1000 -3017.9487 -2.2671044 1000 -3023.077 + -5.211868 1000 -3028.205 -8.1566305 1000 -3033.3333 + -11.101394 1000 -3038.4614 -14.046157 1000 -3043.5898 + -16.990921 1000 -3048.718 -19.935684 1000 -3053.8462 + -22.880447 1000 -3058.9744 -25.82521 1000 -3064.1025 + -28.769974 1000 -3069.2307 -31.714737 1000 -3074.359 + -34.6595 1000 -3079.487 -37.604263 1000 -3084.6155 + -40.549026 1000 -3089.7437 -43.49379 1000 -3094.8718 + -48.418644 1000 -3103.4482 -50.398735 1000 -3106.8965 + -52.378826 1000 -3110.3447 -54.358917 1000 -3113.7932 + -56.33901 1000 -3117.2415 -58.3191 1000 -3120.6897 + -60.29919 1000 -3124.138 -62.27928 1000 -3127.5862 + -64.25938 1000 -3131.0344 -66.23946 1000 -3134.4827 + -68.21956 1000 -3137.9312 -70.199646 1000 -3141.3794 + -72.17974 1000 -3144.8276 -74.15983 1000 -3148.276 + -76.13992 1000 -3151.724 -78.12001 1000 -3155.1724 + -80.100105 1000 -3158.6206 -82.08019 1000 -3162.0688 + -84.06029 1000 -3165.5173 -86.040375 1000 -3168.9656 + -88.02047 1000 -3172.4138 -90.00056 1000 -3175.862 + -91.98065 1000 -3179.3103 -93.96074 1000 -3182.7585 + -95.940834 1000 -3186.2068 -97.92092 1000 -3189.6553 + -99.90102 1000 -3193.1035 -101.8811 1000 -3196.5518 + -109.801506 1000 -3210.3447 -115.74181 1000 -3220.6897 + -121.68211 1000 -3231.0344 -127.62242 1000 -3241.3794 + -133.56273 1000 -3251.724 -139.50304 1000 -3262.0688 + -145.44334 1000 -3272.4138 -151.38365 1000 -3282.7585 + -157.32396 1000 -3293.1035 -163.26427 1000 -3303.4482 + -169.20457 1000 -3313.7932 -175.14487 1000 -3324.138 + -181.08517 1000 -3334.4827 -187.02548 1000 -3344.8276 + -192.96579 1000 -3355.1724 -198.9061 1000 -3365.5173 + -204.8464 1000 -3375.862 -210.78671 1000 -3386.2068 + -216.72702 1000 -3396.5518 -222.66733 1000 -3406.8965 + -228.60764 1000 -3417.2415 -234.54794 1000 -3427.5862 + -240.48825 1000 -3437.9312 -246.42854 1000 -3448.276 + -252.36885 1000 -3458.6206 -258.30917 1000 -3468.9656 + -264.24948 1000 -3479.3103 -270.1898 1000 -3489.6553 + -134.0921 1000 -3210.3447 -140.02502 1000 -3220.6897 + -145.95795 1000 -3231.0344 -151.89085 1000 -3241.3794 + -157.82378 1000 -3251.724 -163.75668 1000 -3262.0688 + -169.6896 1000 -3272.4138 -175.62251 1000 -3282.7585 + -181.55544 1000 -3293.1035 -187.48836 1000 -3303.4482 + -193.42126 1000 -3313.7932 -199.35419 1000 -3324.138 + -205.2871 1000 -3334.4827 -211.22002 1000 -3344.8276 + -217.15294 1000 -3355.1724 -223.08585 1000 -3365.5173 + -229.01877 1000 -3375.862 -234.95168 1000 -3386.2068 + -240.8846 1000 -3396.5518 -246.8175 1000 -3406.8965 + -252.75043 1000 -3417.2415 -258.68335 1000 -3427.5862 + -264.61627 1000 -3437.9312 -270.54916 1000 -3448.276 + -276.4821 1000 -3458.6206 -282.415 1000 -3468.9656 + -288.34793 1000 -3479.3103 -294.28085 1000 -3489.6553 + -72.78531 1000 -3103.4482 -74.76295 1000 -3106.8965 + -76.740585 1000 -3110.3447 -78.71822 1000 -3113.7932 + -80.69586 1000 -3117.2415 -82.6735 1000 -3120.6897 + -84.65114 1000 -3124.138 -86.62878 1000 -3127.5862 + -88.60642 1000 -3131.0344 -90.58406 1000 -3134.4827 + -92.5617 1000 -3137.9312 -94.53934 1000 -3141.3794 + -96.516975 1000 -3144.8276 -98.49461 1000 -3148.276 + -100.47225 1000 -3151.724 -102.44989 1000 -3155.1724 + -104.42753 1000 -3158.6206 -106.40517 1000 -3162.0688 + -108.382805 1000 -3165.5173 -110.36044 1000 -3168.9656 + -112.33808 1000 -3172.4138 -114.31572 1000 -3175.862 + -116.29336 1000 -3179.3103 -118.270996 1000 -3182.7585 + -120.248634 1000 -3186.2068 -122.22627 1000 -3189.6553 + -124.20392 1000 -3193.1035 -126.18156 1000 -3196.5518 + 40.954266 1000 -2905.1282 38.013165 1000 -2910.2563 + 35.07206 1000 -2915.3845 32.130955 1000 -2920.513 + 29.189852 1000 -2925.641 26.248749 1000 -2930.7693 + 23.307646 1000 -2935.8975 20.36654 1000 -2941.0256 + 17.425438 1000 -2946.1538 14.484335 1000 -2951.282 + 11.543231 1000 -2956.4102 8.602127 1000 -2961.5386 + 5.6610236 1000 -2966.6667 2.71992 1000 -2971.795 + -0.22118369 1000 -2976.923 -3.1622872 1000 -2982.0513 + -6.103391 1000 -2987.1794 -9.044495 1000 -2992.3076 + -11.985599 1000 -2997.4358 -14.926702 1000 -3002.5642 + -17.867805 1000 -3007.6924 -20.808908 1000 -3012.8206 + -23.750013 1000 -3017.9487 -26.691116 1000 -3023.077 + -29.63222 1000 -3028.205 -32.573322 1000 -3033.3333 + -35.514427 1000 -3038.4614 -38.455532 1000 -3043.5898 + -41.396633 1000 -3048.718 -44.337738 1000 -3053.8462 + -47.278843 1000 -3058.9744 -50.219944 1000 -3064.1025 + -53.16105 1000 -3069.2307 -56.102154 1000 -3074.359 + -59.043255 1000 -3079.487 -61.98436 1000 -3084.6155 + -64.92546 1000 -3089.7437 -67.86657 1000 -3094.8718 + 99.26926 1000 -2803.4482 97.29162 1000 -2806.8965 + 95.31397 1000 -2810.3447 93.336334 1000 -2813.7932 + 91.358696 1000 -2817.2415 89.38106 1000 -2820.6897 + 87.40342 1000 -2824.138 85.42578 1000 -2827.5862 + 83.44814 1000 -2831.0344 81.470505 1000 -2834.4827 + 79.49287 1000 -2837.9312 77.51523 1000 -2841.3794 + 75.53759 1000 -2844.8276 73.55995 1000 -2848.276 + 71.58231 1000 -2851.724 69.604675 1000 -2855.1724 + 67.62704 1000 -2858.6206 65.6494 1000 -2862.0688 + 63.671757 1000 -2865.5173 61.69412 1000 -2868.9656 + 59.71648 1000 -2872.4138 57.738842 1000 -2875.862 + 55.761204 1000 -2879.3103 53.783566 1000 -2882.7585 + 51.805927 1000 -2886.2068 49.828285 1000 -2889.6553 + 47.850647 1000 -2893.1035 45.87301 1000 -2896.5518 + 267.36884 1000 -2510.3447 261.4359 1000 -2520.6897 + 255.50298 1000 -2531.0344 249.57005 1000 -2541.3794 + 243.63713 1000 -2551.724 237.70421 1000 -2562.0688 + 231.77127 1000 -2572.4138 225.83835 1000 -2582.7585 + 219.90543 1000 -2593.1035 213.9725 1000 -2603.4482 + 208.03957 1000 -2613.7932 202.10664 1000 -2624.138 + 196.17372 1000 -2634.4827 190.24078 1000 -2644.8276 + 184.30786 1000 -2655.1724 178.37494 1000 -2665.5173 + 172.44202 1000 -2675.862 166.50908 1000 -2686.2068 + 160.57616 1000 -2696.5518 154.64323 1000 -2706.8965 + 148.71031 1000 -2717.2415 142.77737 1000 -2727.5862 + 136.84445 1000 -2737.9312 130.91153 1000 -2748.276 + 124.9786 1000 -2758.6206 119.04567 1000 -2768.9656 + 113.11275 1000 -2779.3103 107.17982 1000 -2789.6553 + + + 2514.8944015 + + + 3652.4140363 + + + + + + + 1 11 502 168 8 201 + 636 474 11 12 501 502 + 201 200 637 636 12 13 + 500 501 200 199 638 637 + 13 14 499 500 199 198 + 639 638 14 15 498 499 + 198 197 640 639 15 16 + 497 498 197 196 641 640 + 16 17 496 497 196 195 + 642 641 17 18 495 496 + 195 194 643 642 18 19 + 494 495 194 193 644 643 + 19 20 493 494 193 192 + 645 644 20 21 492 493 + 192 191 646 645 21 22 + 491 492 191 190 647 646 + 22 23 490 491 190 189 + 648 647 23 24 489 490 + 189 188 649 648 24 25 + 488 489 188 187 650 649 + 25 26 487 488 187 186 + 651 650 26 27 486 487 + 186 185 652 651 27 28 + 485 486 185 184 653 652 + 28 29 484 485 184 183 + 654 653 29 30 483 484 + 183 182 655 654 30 31 + 482 483 182 181 656 655 + 31 32 481 482 181 180 + 657 656 32 33 480 481 + 180 179 658 657 33 34 + 479 480 179 178 659 658 + 34 35 478 479 178 177 + 660 659 35 36 477 478 + 177 176 661 660 36 37 + 476 477 176 175 662 661 + 37 38 475 476 175 174 + 663 662 38 2 169 475 + 174 10 503 663 2 42 + 531 169 10 229 664 503 + 42 43 530 531 229 228 + 665 664 43 44 529 530 + 228 227 666 665 44 45 + 528 529 227 226 667 666 + 45 46 527 528 226 225 + 668 667 46 47 526 527 + 225 224 669 668 47 48 + 525 526 224 223 670 669 + 48 49 524 525 223 222 + 671 670 49 50 523 524 + 222 221 672 671 50 51 + 522 523 221 220 673 672 + 51 52 521 522 220 219 + 674 673 52 53 520 521 + 219 218 675 674 53 54 + 519 520 218 217 676 675 + 54 55 518 519 217 216 + 677 676 55 56 517 518 + 216 215 678 677 56 57 + 516 517 215 214 679 678 + 57 58 515 516 214 213 + 680 679 58 59 514 515 + 213 212 681 680 59 60 + 513 514 212 211 682 681 + 60 61 512 513 211 210 + 683 682 61 62 511 512 + 210 209 684 683 62 63 + 510 511 209 208 685 684 + 63 64 509 510 208 207 + 686 685 64 65 508 509 + 207 206 687 686 65 66 + 507 508 206 205 688 687 + 66 67 506 507 205 204 + 689 688 67 68 505 506 + 204 203 690 689 68 69 + 504 505 203 202 691 690 + 69 3 170 504 202 39 + 532 691 3 70 570 170 + 39 267 692 532 70 71 + 569 570 267 266 693 692 + 71 72 568 569 266 265 + 694 693 72 73 567 568 + 265 264 695 694 73 74 + 566 567 264 263 696 695 + 74 75 565 566 263 262 + 697 696 75 76 564 565 + 262 261 698 697 76 77 + 563 564 261 260 699 698 + 77 78 562 563 260 259 + 700 699 78 79 561 562 + 259 258 701 700 79 80 + 560 561 258 257 702 701 + 80 81 559 560 257 256 + 703 702 81 82 558 559 + 256 255 704 703 82 83 + 557 558 255 254 705 704 + 83 84 556 557 254 253 + 706 705 84 85 555 556 + 253 252 707 706 85 86 + 554 555 252 251 708 707 + 86 87 553 554 251 250 + 709 708 87 88 552 553 + 250 249 710 709 88 89 + 551 552 249 248 711 710 + 89 90 550 551 248 247 + 712 711 90 91 549 550 + 247 246 713 712 91 92 + 548 549 246 245 714 713 + 92 93 547 548 245 244 + 715 714 93 94 546 547 + 244 243 716 715 94 95 + 545 546 243 242 717 716 + 95 96 544 545 242 241 + 718 717 96 97 543 544 + 241 240 719 718 97 98 + 542 543 240 239 720 719 + 98 99 541 542 239 238 + 721 720 99 100 540 541 + 238 237 722 721 100 101 + 539 540 237 236 723 722 + 101 102 538 539 236 235 + 724 723 102 103 537 538 + 235 234 725 724 103 104 + 536 537 234 233 726 725 + 104 105 535 536 233 232 + 727 726 105 106 534 535 + 232 231 728 727 106 107 + 533 534 231 230 729 728 + 107 4 171 533 230 164 + 571 729 4 108 599 171 + 164 295 730 571 108 109 + 598 599 295 294 731 730 + 109 110 597 598 294 293 + 732 731 110 111 596 597 + 293 292 733 732 111 112 + 595 596 292 291 734 733 + 112 113 594 595 291 290 + 735 734 113 114 593 594 + 290 289 736 735 114 115 + 592 593 289 288 737 736 + 115 116 591 592 288 287 + 738 737 116 117 590 591 + 287 286 739 738 117 118 + 589 590 286 285 740 739 + 118 119 588 589 285 284 + 741 740 119 120 587 588 + 284 283 742 741 120 121 + 586 587 283 282 743 742 + 121 122 585 586 282 281 + 744 743 122 123 584 585 + 281 280 745 744 123 124 + 583 584 280 279 746 745 + 124 125 582 583 279 278 + 747 746 125 126 581 582 + 278 277 748 747 126 127 + 580 581 277 276 749 748 + 127 128 579 580 276 275 + 750 749 128 129 578 579 + 275 274 751 750 129 130 + 577 578 274 273 752 751 + 130 131 576 577 273 272 + 753 752 131 132 575 576 + 272 271 754 753 132 133 + 574 575 271 270 755 754 + 133 134 573 574 270 269 + 756 755 134 135 572 573 + 269 268 757 756 135 5 + 172 572 268 165 600 757 + 5 136 629 172 165 323 + 758 600 136 137 628 629 + 323 322 759 758 137 138 + 627 628 322 321 760 759 + 138 139 626 627 321 320 + 761 760 139 140 625 626 + 320 319 762 761 140 141 + 624 625 319 318 763 762 + 141 142 623 624 318 317 + 764 763 142 143 622 623 + 317 316 765 764 143 144 + 621 622 316 315 766 765 + 144 145 620 621 315 314 + 767 766 145 146 619 620 + 314 313 768 767 146 147 + 618 619 313 312 769 768 + 147 148 617 618 312 311 + 770 769 148 149 616 617 + 311 310 771 770 149 150 + 615 616 310 309 772 771 + 150 151 614 615 309 308 + 773 772 151 152 613 614 + 308 307 774 773 152 153 + 612 613 307 306 775 774 + 153 154 611 612 306 305 + 776 775 154 155 610 611 + 305 304 777 776 155 156 + 609 610 304 303 778 777 + 156 157 608 609 303 302 + 779 778 157 158 607 608 + 302 301 780 779 158 159 + 606 607 301 300 781 780 + 159 160 605 606 300 299 + 782 781 160 161 604 605 + 299 298 783 782 161 162 + 603 604 298 297 784 783 + 162 163 602 603 297 296 + 785 784 163 0 173 602 + 296 6 601 785 41 351 + 786 630 5 136 629 172 + 351 350 787 786 136 137 + 628 629 350 349 788 787 + 137 138 627 628 349 348 + 789 788 138 139 626 627 + 348 347 790 789 139 140 + 625 626 347 346 791 790 + 140 141 624 625 346 345 + 792 791 141 142 623 624 + 345 344 793 792 142 143 + 622 623 344 343 794 793 + 143 144 621 622 343 342 + 795 794 144 145 620 621 + 342 341 796 795 145 146 + 619 620 341 340 797 796 + 146 147 618 619 340 339 + 798 797 147 148 617 618 + 339 338 799 798 148 149 + 616 617 338 337 800 799 + 149 150 615 616 337 336 + 801 800 150 151 614 615 + 336 335 802 801 151 152 + 613 614 335 334 803 802 + 152 153 612 613 334 333 + 804 803 153 154 611 612 + 333 332 805 804 154 155 + 610 611 332 331 806 805 + 155 156 609 610 331 330 + 807 806 156 157 608 609 + 330 329 808 807 157 158 + 607 608 329 328 809 808 + 158 159 606 607 328 327 + 810 809 159 160 605 606 + 327 326 811 810 160 161 + 604 605 326 325 812 811 + 161 162 603 604 325 324 + 813 812 162 163 602 603 + 324 7 631 813 163 0 + 173 602 632 171 4 40 + 814 599 108 379 814 599 + 108 379 815 598 109 378 + 815 598 109 378 816 597 + 110 377 816 597 110 377 + 817 596 111 376 817 596 + 111 376 818 595 112 375 + 818 595 112 375 819 594 + 113 374 819 594 113 374 + 820 593 114 373 820 593 + 114 373 821 592 115 372 + 821 592 115 372 822 591 + 116 371 822 591 116 371 + 823 590 117 370 823 590 + 117 370 824 589 118 369 + 824 589 118 369 825 588 + 119 368 825 588 119 368 + 826 587 120 367 826 587 + 120 367 827 586 121 366 + 827 586 121 366 828 585 + 122 365 828 585 122 365 + 829 584 123 364 829 584 + 123 364 830 583 124 363 + 830 583 124 363 831 582 + 125 362 831 582 125 362 + 832 581 126 361 832 581 + 126 361 833 580 127 360 + 833 580 127 360 834 579 + 128 359 834 579 128 359 + 835 578 129 358 835 578 + 129 358 836 577 130 357 + 836 577 130 357 837 576 + 131 356 837 576 131 356 + 838 575 132 355 838 575 + 132 355 839 574 133 354 + 839 574 133 354 840 573 + 134 353 840 573 134 353 + 841 572 135 352 841 572 + 135 352 630 172 5 41 + 166 417 842 633 3 70 + 570 170 417 416 843 842 + 70 71 569 570 416 415 + 844 843 71 72 568 569 + 415 414 845 844 72 73 + 567 568 414 413 846 845 + 73 74 566 567 413 412 + 847 846 74 75 565 566 + 412 411 848 847 75 76 + 564 565 411 410 849 848 + 76 77 563 564 410 409 + 850 849 77 78 562 563 + 409 408 851 850 78 79 + 561 562 408 407 852 851 + 79 80 560 561 407 406 + 853 852 80 81 559 560 + 406 405 854 853 81 82 + 558 559 405 404 855 854 + 82 83 557 558 404 403 + 856 855 83 84 556 557 + 403 402 857 856 84 85 + 555 556 402 401 858 857 + 85 86 554 555 401 400 + 859 858 86 87 553 554 + 400 399 860 859 87 88 + 552 553 399 398 861 860 + 88 89 551 552 398 397 + 862 861 89 90 550 551 + 397 396 863 862 90 91 + 549 550 396 395 864 863 + 91 92 548 549 395 394 + 865 864 92 93 547 548 + 394 393 866 865 93 94 + 546 547 393 392 867 866 + 94 95 545 546 392 391 + 868 867 95 96 544 545 + 391 390 869 868 96 97 + 543 544 390 389 870 869 + 97 98 542 543 389 388 + 871 870 98 99 541 542 + 388 387 872 871 99 100 + 540 541 387 386 873 872 + 100 101 539 540 386 385 + 874 873 101 102 538 539 + 385 384 875 874 102 103 + 537 538 384 383 876 875 + 103 104 536 537 383 382 + 877 876 104 105 535 536 + 382 381 878 877 105 106 + 534 535 381 380 879 878 + 106 107 533 534 380 40 + 632 879 107 4 171 533 + 167 445 880 634 2 42 + 531 169 445 444 881 880 + 42 43 530 531 444 443 + 882 881 43 44 529 530 + 443 442 883 882 44 45 + 528 529 442 441 884 883 + 45 46 527 528 441 440 + 885 884 46 47 526 527 + 440 439 886 885 47 48 + 525 526 439 438 887 886 + 48 49 524 525 438 437 + 888 887 49 50 523 524 + 437 436 889 888 50 51 + 522 523 436 435 890 889 + 51 52 521 522 435 434 + 891 890 52 53 520 521 + 434 433 892 891 53 54 + 519 520 433 432 893 892 + 54 55 518 519 432 431 + 894 893 55 56 517 518 + 431 430 895 894 56 57 + 516 517 430 429 896 895 + 57 58 515 516 429 428 + 897 896 58 59 514 515 + 428 427 898 897 59 60 + 513 514 427 426 899 898 + 60 61 512 513 426 425 + 900 899 61 62 511 512 + 425 424 901 900 62 63 + 510 511 424 423 902 901 + 63 64 509 510 423 422 + 903 902 64 65 508 509 + 422 421 904 903 65 66 + 507 508 421 420 905 904 + 66 67 506 507 420 419 + 906 905 67 68 505 506 + 419 418 907 906 68 69 + 504 505 418 166 633 907 + 69 3 170 504 9 473 + 908 635 1 11 502 168 + 473 472 909 908 11 12 + 501 502 472 471 910 909 + 12 13 500 501 471 470 + 911 910 13 14 499 500 + 470 469 912 911 14 15 + 498 499 469 468 913 912 + 15 16 497 498 468 467 + 914 913 16 17 496 497 + 467 466 915 914 17 18 + 495 496 466 465 916 915 + 18 19 494 495 465 464 + 917 916 19 20 493 494 + 464 463 918 917 20 21 + 492 493 463 462 919 918 + 21 22 491 492 462 461 + 920 919 22 23 490 491 + 461 460 921 920 23 24 + 489 490 460 459 922 921 + 24 25 488 489 459 458 + 923 922 25 26 487 488 + 458 457 924 923 26 27 + 486 487 457 456 925 924 + 27 28 485 486 456 455 + 926 925 28 29 484 485 + 455 454 927 926 29 30 + 483 484 454 453 928 927 + 30 31 482 483 453 452 + 929 928 31 32 481 482 + 452 451 930 929 32 33 + 480 481 451 450 931 930 + 33 34 479 480 450 449 + 932 931 34 35 478 479 + 449 448 933 932 35 36 + 477 478 448 447 934 933 + 36 37 476 477 447 446 + 935 934 37 38 475 476 + 446 167 634 935 38 2 + 169 475 + + + 8 16 24 32 40 48 + 56 64 72 80 88 96 + 104 112 120 128 136 144 + 152 160 168 176 184 192 + 200 208 216 224 232 240 + 248 256 264 272 280 288 + 296 304 312 320 328 336 + 344 352 360 368 376 384 + 392 400 408 416 424 432 + 440 448 456 464 472 480 + 488 496 504 512 520 528 + 536 544 552 560 568 576 + 584 592 600 608 616 624 + 632 640 648 656 664 672 + 680 688 696 704 712 720 + 728 736 744 752 760 768 + 776 784 792 800 808 816 + 824 832 840 848 856 864 + 872 880 888 896 904 912 + 920 928 936 944 952 960 + 968 976 984 992 1000 1008 + 1016 1024 1032 1040 1048 1056 + 1064 1072 1080 1088 1096 1104 + 1112 1120 1128 1136 1144 1152 + 1160 1168 1176 1184 1192 1200 + 1208 1216 1224 1232 1240 1248 + 1256 1264 1272 1280 1288 1296 + 1304 1312 1320 1328 1336 1344 + 1352 1360 1368 1376 1384 1392 + 1400 1408 1416 1424 1432 1440 + 1448 1456 1464 1472 1480 1488 + 1496 1504 1512 1520 1528 1536 + 1544 1552 1560 1568 1576 1584 + 1592 1600 1608 1616 1624 1632 + 1640 1648 1656 1664 1672 1680 + 1688 1696 1704 1712 1720 1728 + 1736 1744 1752 1760 1768 1776 + 1784 1792 1800 1808 1816 1824 + 1832 1840 1848 1856 1864 1872 + 1880 1888 1896 1904 1912 1920 + 1928 1936 1944 1952 1960 1968 + 1976 1984 1992 2000 2008 2016 + 2024 2032 2040 2048 2056 2064 + 2072 2080 2088 2096 2104 2112 + 2120 2128 2136 2144 2152 2160 + 2168 2176 2184 2192 2200 2208 + 2216 2224 2232 2240 2248 2256 + 2264 2272 2280 2288 2296 2304 + 2312 2320 2328 2336 2344 2352 + 2360 2368 2376 2384 2392 2400 + 2408 2416 2424 2432 2440 2448 + 2456 2464 2472 2480 + + + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 12 12 + 12 12 12 12 + + + + + diff --git a/inputFiles/coverageSmokes/grav_seg_1d_DBC_small_mesh.xml b/inputFiles/coverageSmokes/grav_seg_1d_DBC_small_mesh.xml new file mode 100644 index 00000000000..7da88f54fd0 --- /dev/null +++ b/inputFiles/coverageSmokes/grav_seg_1d_DBC_small_mesh.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/isothm_mass_inj_table_fast_outputs.xml b/inputFiles/coverageSmokes/isothm_mass_inj_table_fast_outputs.xml new file mode 100644 index 00000000000..09e653d7b18 --- /dev/null +++ b/inputFiles/coverageSmokes/isothm_mass_inj_table_fast_outputs.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/inputFiles/coverageSmokes/kgdViscosityDominated_small_mesh.xml b/inputFiles/coverageSmokes/kgdViscosityDominated_small_mesh.xml new file mode 100644 index 00000000000..60b2a1d0d6f --- /dev/null +++ b/inputFiles/coverageSmokes/kgdViscosityDominated_small_mesh.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/inputFiles/coverageSmokes/staircase3d_tet_coverage.vtu b/inputFiles/coverageSmokes/staircase3d_tet_coverage.vtu new file mode 100644 index 00000000000..72190fb9653 --- /dev/null +++ b/inputFiles/coverageSmokes/staircase3d_tet_coverage.vtu @@ -0,0 +1,255 @@ + + + + + + + 35 37 212 213 214 241 + 256 257 258 264 414 427 + 456 468 486 490 491 515 + 516 539 589 605 630 631 + 646 661 669 715 718 719 + 739 809 810 814 818 842 + 867 870 871 885 918 928 + 931 945 947 959 960 + + + + + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 0.11 0.11 0.11 + 0.11 0.11 0.11 + + + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 2.5e-16 + 2.5e-16 2.5e-16 2.5e-16 + + + 4.3301270189e-16 + + + 4.3301270189e-16 + + + + + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + 0.005 0.995 0.005 0.995 0.005 0.995 + + + 0.99501256273 + + + 0.99501256273 + + + + + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 4 4 4 + 4 4 4 + + + 159 210 490 515 573 598 + 645 722 740 821 845 956 + 1003 1019 1117 1134 1264 1290 + 1322 1454 1464 1469 1631 1637 + 1689 1753 1763 1828 1838 1887 + 2084 2189 2196 2203 2218 2466 + 2495 2536 2552 2593 2598 2626 + 2632 2683 2700 2723 2733 2764 + 2789 2793 2795 2833 2841 2855 + 2860 2866 2867 2872 2878 2898 + 2899 2903 2931 2944 2949 3083 + 3084 3095 3096 + + + + + 8.31685098013915 0 1.716600433082997 8.736871856380187 1.263128143619813 1.259240372343239 + 9.272800205258896 0 0.6838575978415236 9.489776110860404 0 1.249676492542829 + 10 0.4995307143941714 1.249538658589354 8.729998852157943 1.270001147842057 8.75 + 9.500469285605828 0 8.749538658589353 10 0.511441783475096 8.750019825172702 + 10 0.7271997947411037 8.18550457900122 8.29447217172432 0 0.7698122537158085 + 8.805555555555557 0 1.25 9.276530006661368 0 1.813877849958277 + 10 1.194444444444444 8.75 10 0.7319952536871392 9.314634197207695 + 8.437711738717965 0.6558977212005124 2.5 10 1.194444444444444 1.25 + 10 0.724031718907258 0.6837427461074205 9.275968281092743 0 8.18374274610742 + 8.805555555555555 0 8.75 8.459158235653726 0.6181048510255562 0 + 10 0.7534376582239992 1.811736108068244 9.246562341776002 0 9.311736108068244 + 8.313480626727392 0 8.26912314331119 8.437711738717965 0.6558977212005124 7.5 + 8.61777020418923 1.283719970254023 0 8.436930648841711 0.6556746962602613 10 + 9.209627182306447 0.7903728176935534 0 9.177689128372437 0.8223108716275627 7.5 + 9.164583333333333 0.8354166666666671 10 9.177689128372437 0.8223108716275627 2.5 + 8.137044990826947 0 9.203749981742797 10 0 9.166666666666666 + 10 0 0.8333333333333333 10 0 8.333333333333334 + 10 0 1.666666666666667 10 1 10 + 9 0 0 10 1 7.5 + 9 0 7.5 9 0 2.5 + 10 1 0 10 0 0 + 10 0 7.5 10 0 2.5 + 10 0 10 10 1 2.5 + 9 0 10 + + + 8.3301188175 + + + 14.177446879 + + + + + + + 2 3 4 1 6 7 + 8 5 3 2 10 1 + 3 10 11 1 12 8 + 7 5 7 13 12 5 + 4 15 16 1 4 16 + 2 1 6 17 18 5 + 8 17 6 5 9 10 + 2 1 4 20 15 1 + 10 0 11 1 18 21 + 6 5 18 17 22 5 + 9 2 19 1 18 25 + 21 5 26 19 2 1 + 10 9 0 1 22 17 + 23 5 2 16 26 1 + 11 0 14 1 27 17 + 8 5 11 14 29 1 + 23 17 27 5 29 20 + 11 1 18 30 25 5 + 19 26 24 1 21 28 + 13 5 21 25 28 5 + 21 18 30 25 31 7 + 6 13 4 3 2 32 + 7 8 33 6 3 34 + 4 11 19 9 36 2 + 22 38 23 17 11 39 + 14 0 26 19 36 2 + 32 2 4 16 6 8 + 33 17 38 27 23 17 + 14 29 39 11 21 31 + 6 13 20 4 34 11 + 41 26 2 16 8 27 + 42 17 32 41 2 16 + 20 43 29 11 42 33 + 8 17 13 21 44 28 + 34 43 20 11 21 44 + 31 13 27 38 42 17 + 39 29 43 11 27 42 + 37 8 41 26 16 40 + 28 21 46 25 26 36 + 41 2 33 31 7 6 + 30 46 21 25 4 34 + 3 32 44 13 28 35 + 46 44 21 28 29 45 + 43 20 11 4 1 20 + 11 1 4 3 6 13 + 5 21 5 13 6 7 + + + 4 8 12 16 20 24 + 28 32 36 40 44 48 + 52 56 60 64 68 72 + 76 80 84 88 92 96 + 100 104 108 112 116 120 + 124 128 132 136 140 144 + 148 152 156 160 164 168 + 172 176 180 184 188 192 + 196 200 204 208 212 216 + 220 224 228 232 236 240 + 244 248 252 256 260 264 + 268 272 276 + + + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 + + + + + diff --git a/inputFiles/coverageSmokes/staircase_co2_wells_hybrid_small_mesh.xml b/inputFiles/coverageSmokes/staircase_co2_wells_hybrid_small_mesh.xml new file mode 100644 index 00000000000..43d30aca5be --- /dev/null +++ b/inputFiles/coverageSmokes/staircase_co2_wells_hybrid_small_mesh.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/inputFiles/coverageSmokes/staircase_co2_wells_small_mesh.xml b/inputFiles/coverageSmokes/staircase_co2_wells_small_mesh.xml new file mode 100644 index 00000000000..f6ccbf353ad --- /dev/null +++ b/inputFiles/coverageSmokes/staircase_co2_wells_small_mesh.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/inputFiles/coverageSmokes/synthetic_isothermal_coverage.vtu b/inputFiles/coverageSmokes/synthetic_isothermal_coverage.vtu new file mode 100644 index 00000000000..b743a75dfcd --- /dev/null +++ b/inputFiles/coverageSmokes/synthetic_isothermal_coverage.vtu @@ -0,0 +1,542 @@ + + + + + + + + + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 + + + 520 791 1836 1932 3376 3644 + 4803 5039 5192 5246 5411 5430 + 5457 6072 6296 6696 7346 7676 + 7798 8227 8618 8935 9690 11258 + 11522 12212 12338 12766 12842 13299 + 13918 14737 15324 15950 15991 16289 + 16572 16818 16930 17104 18006 18026 + 18291 19030 19412 20093 20099 21006 + 21295 22046 22093 22562 22629 22681 + 22725 23205 23431 23562 25652 26402 + 26672 26751 26785 27262 27381 27573 + 27600 27861 28174 28354 29093 29164 + 29261 29886 30264 30364 30716 31017 + 31473 31625 31676 31715 31809 32225 + 32254 32320 32428 32843 33290 34400 + 34610 35074 35357 35479 36099 36410 + 37005 37038 37869 38563 38623 40861 + 41504 41505 42392 42631 43001 43732 + 44198 44321 44688 44732 45067 45283 + 45505 46935 47772 48916 50187 50373 + 50378 50724 51326 51424 51847 52177 + 52846 53359 54878 54961 55469 56865 + 57191 57535 57726 58250 58645 60025 + 60085 60477 61275 61415 61639 61951 + 63405 63476 64415 64819 64884 64994 + 65688 66195 66206 67560 67936 68220 + 68741 69025 69188 69372 70174 70185 + 70206 70457 70976 71131 71311 71357 + 72638 73005 73677 73800 74087 74090 + 74171 74931 75181 75248 76166 76559 + 76990 77988 78045 78084 78161 78303 + 78406 78448 78540 78686 79432 80122 + 80465 81843 81987 82145 83476 83881 + 84294 84551 86307 86759 87869 87962 + 88153 89260 89309 90426 90466 90467 + 93414 93704 94204 94262 94423 94680 + 94984 95010 95082 95456 95511 95742 + 95789 96217 96316 96809 97554 97862 + 97877 98047 98053 99228 100741 101124 + 101556 102039 102753 102868 103387 104732 + 105396 105731 105884 106104 107035 107201 + 107607 108063 109278 109478 110309 110370 + 110739 111625 111654 111896 112143 112153 + 112517 113218 113359 113574 113912 114181 + 114872 114960 115373 115757 116347 119531 + 119979 120367 121216 121700 122251 122371 + 122738 122982 123281 124548 125654 126070 + 126705 126821 126851 127294 127715 128193 + 128468 128572 128901 129276 129303 129581 + 130369 130598 131265 131604 132496 132677 + 133714 133715 134087 135335 + + + + + 6414.460252962066 1444.205182200484 -500 16106.88076660959 7183.896108400462 -500 + 6964.846365351352 2124.342194884604 -500 15268.22134232392 7400.008845890813 -500 + 6047.922558528833 1526.479404759503 -2000 6947.451697584778 1073.778241400413 -2000 + 14928.25639073351 7372.64062876512 -2000 15927.66025517862 7191.62096586634 -2000 + 7017.285954144765 2045.79697312343 -2000 6296.9909852321 2221.043479928546 -2000 + 6419.019235960423 1476.725674451538 -871.811885248 6351.88491047623 1895.647306200925 -867.272606624 + 15425.12484090007 7309.49984638848 -1557.25766899 15433.53351780857 7550.393954079803 -1557.32988096 + 15654.61183291513 7428.327763482946 -1558.16154905 15688.90367429904 7219.800498002436 -1558.10101004 + 15541.85486219347 7039.013576270028 -1557.99751826 7085.435691758681 1919.536678805666 -920.605071616 + 6942.808439844563 1718.616662903383 -909.657731759 15827.97292406456 7578.191430109337 -1557.3110404 + 16018.31309188429 7506.424857686015 -1554.75842912 15872.34349239403 6985.663840996416 -1556.87089382 + 16155.27732517789 7073.743355157525 -1551.81837832 16231.8056830548 7376.564401063879 -1549.75744275 + 7107.83392674774 1512.226906748931 -922.345885261 6838.631663958862 1987.228371859431 -901.820567586 + 6727.610494343977 1727.499175183294 -893.62720865 6840.865123573634 1447.020328325743 -901.987111805 + 6519.295165043368 1725.882008942589 -878.735593292 6598.778845238688 2018.697681279134 -884.339631809 + 6608.247722237624 1470.500082793522 -885.013808666 15922.53773134394 7313.225873204623 -1556.26057398 + 6324.278651276398 1687.02599639361 -1015.42933171 6419.019235103957 1476.725675339948 -1021.81188519 + 6351.884909993321 1895.647307309604 -1017.27260659 15425.1248377579 7309.499845142172 -1707.25766897 + 15433.53351446838 7550.393952604836 -1707.32988093 15654.61182936576 7428.327761902474 -1708.16154905 + 15688.90367092147 7219.80049652948 -1708.10101005 15541.85485910991 7039.013575056141 -1707.99751825 + 7106.284701177496 1715.727156350586 -1072.2252949 7085.435691033348 1919.536678901119 -1070.60507156 + 6942.808439097741 1718.616663217326 -1059.6577317 15827.97292024998 7578.191428349804 -1707.31104043 + 15633.98146066355 7650.96960359091 -1708.17291011 16018.31308777003 7506.424855697938 -1704.7584292 + 15872.34348895454 6985.66383940205 -1706.87089386 16155.27732113549 7073.743353097027 -1701.81837843 + 7107.833926003175 1512.226906640891 -1072.3458852 6838.631663273449 1987.228372583925 -1051.82056754 + 6727.61049359255 1727.499175804629 -1043.6272086 6840.865122969956 1447.020328704928 -1051.98711176 + 6519.295164379726 1725.882009822505 -1028.73559325 6598.778844944249 2018.697682348946 -1034.33963179 + 6608.247721265633 1470.500083545408 -1035.0138086 15922.53772754576 7313.225871418299 -1706.26057403 + 15631.7521007776 7539.302538322157 -1198.25596637 15588.35129136617 6679.158504323897 -1357.64658739 + 15643.63246283868 7770.830822385116 -1391.68963049 6835.555088906219 1550.055426963183 -690.093616723 + 15461.62201165479 7001.667271148624 -864.896569104 7330.604417249119 1881.808576096243 -796.452357741 + 16159.80318500536 6996.914529080244 -1435.92019943 16228.21040849824 6991.495037977197 -1268.79449181 + 6557.238623479765 2286.764955752337 -744.191950809 15567.06969935776 7253.434391288517 -1460.0730734 + 6144.237572425752 1517.472341711509 -741.965801746 15971.78521805879 7641.868965532365 -1472.46452965 + 6841.92243156029 1339.022698665665 -794.17099006 15380.93286508278 7421.057931377661 -1028.68522669 + 16128.19486994485 7728.690277970626 -1301.23409517 16495.07276194134 7668.692740324926 -780.130497947 + 6729.475085619836 1892.312536545452 -803.607191001 16208.98098672995 7381.992356408715 -1095.99593586 + 15688.0062973088 6650.509640038711 -1190.50503072 15661.8857759854 7292.545500402554 -1324.2522263 + 15714.04163206046 8081.51161518462 -1099.43174016 15822.97310683744 6826.312815714578 -689.795592256 + 16067.62006794119 7457.920527276538 -1399.39500644 15254.79904381521 7369.514562716516 -1395.60362123 + 15552.46433066739 7585.777078286873 -1497.68147388 15150.51319270264 7083.206093482272 -1141.38821205 + 7150.867595544617 2223.409156595008 -849.363849666 6377.113885982238 1630.112836719984 -800.511677503 + 15509.0312886289 6894.254235761543 -1496.35823024 14941.1054517046 6549.430169952467 -1048.0009123 + 15710.81882013538 6838.963962032019 -1432.12004984 15932.37229415238 7138.95984656683 -1494.58962605 + 7024.141135999898 1611.777623856522 -843.189075135 15207.78413831427 7044.66428168552 -1334.71402278 + 15954.36992499789 6572.620671423286 -948.841834276 15286.67888392361 6976.901421598507 -1428.45591986 + 15913.58235195537 7041.503906388943 -1376.22532424 15092.63384389589 7504.08129636816 -1235.42220432 + 7018.805623653705 2358.171719639931 -645.579568269 16034.86254689591 7715.727590828663 -907.924185739 + 16180.45901368084 7480.715669306353 -1223.49432616 16397.69851362052 6909.665773308187 -852.65886241 + 15619.59167894527 6905.716645096024 -1264.24351644 15303.30030318709 7256.449051501891 -1505.82923813 + 6271.646307275691 1954.356463044872 -667.061338258 15676.78607514222 7837.556830215353 -1451.01874439 + 15882.39247652947 7943.670198123707 -678.778784493 6654.972747517825 1577.577030804026 -833.933798399 + 15861.60797771494 7450.709907998588 -1510.73791507 15668.9110525658 7336.720245216547 -1400.74597627 + 7162.079473256647 1808.416741296722 -876.161046991 15423.96605542521 7569.822675340642 -1436.88438418 + 15247.75688072624 7649.314628467463 -789.476409662 15735.63664181413 6982.5989554007 -1110.98954118 + 15942.68793236336 7073.363581380735 -1017.16449274 15919.85778659095 7319.615563004485 -1446.02692966 + 16075.59095682218 7341.782373474422 -1337.86553015 15940.48859618625 7732.449457341471 -1026.37991058 + 15836.59086224634 6820.897632889554 -1510.35098416 6670.383489030558 923.5914071080151 -614.449010889 + 15629.08549224558 7218.108221210961 -1509.57674085 6947.743618676772 2263.693278239357 -791.404729254 + 15782.237990741 7524.326609718499 -1267.17083606 16047.74609068493 7016.730328775448 -1196.55247288 + 15522.081641043 7685.15804370933 -1633.17291005 16143.14836261805 7684.611120512637 -1625.42623808 + 15669.0919484872 7206.668016394629 -1633.10101004 6565.192092788222 1585.329346831217 -956.540811847 + 7227.899110159976 1733.786103278724 -1006.51118622 6952.54312852688 2082.922739102815 -985.569589126 + 6942.905968981767 1424.259148672689 -983.420866922 16068.11177085479 6983.40062545337 -1635.81286343 + 15969.73666844154 7398.189049176358 -1651.19017377 6879.998298306585 1757.519605207086 -964.233421532 + 16106.6442347609 7366.151948930444 -1602.07496845 15777.08261067931 6849.039018720965 -1607.73717514 + 15363.38775905862 7071.156494753336 -1653.12327022 6572.750756365067 1914.528398107353 -975.923615811 + 15737.8287806317 7489.791276979209 -1606.76737479 15404.35700540844 7430.187248715652 -1604.68064833 + 15363.63305672098 7451.408446755489 -1660.38135891 15751.20511985038 6786.58795617967 -1659.96683382 + 7134.283815921423 1437.692890622212 -1024.01833236 15653.6550295367 7540.609603105589 -1666.8269957 + 7127.751025516122 2014.74781645143 -1024.84737532 15462.0840697585 7029.215372842845 -1607.80299279 + 6404.251001458512 1698.442238673687 -921.210844444 6855.17357336745 2292.846207078567 -954.286395752 + 6926.26140494414 1801.87180581976 -1009.72382129 6571.723837631665 1953.762127178193 -929.712959777 + 6711.321889452413 1602.813537737329 -996.705076484 6472.884946181465 1413.180517726189 -923.151477367 + 15878.83588765235 7144.556324795994 -1598.31555276 6318.653686513984 1665.10771364739 -973.255436582 + 7209.028698966282 1522.94105339638 -977.549134224 6815.085330086868 2640.746191513711 -1500.00825858 + 6405.978124025025 1809.25863360165 -1247.45762526 6068.544611816093 1434.24286217427 -1452.06513568 + 6140.716111334389 1799.446215848901 -1716.31608807 6719.281951138892 1613.196706236866 -1154.74706156 + 6896.686222101682 2182.396554935935 -1366.45940414 15890.16254027279 7160.562769208526 -1827.35651757 + 7216.836689707766 1381.0648062701 -1169.05745533 6946.447891241956 1510.990701369663 -1293.82305236 + 6678.459690029217 1206.820825478298 -1593.41433653 15415.23591294995 7689.268269266133 -1797.6598104 + 7252.406911739445 2226.004781416869 -1692.92422277 6130.294398272864 1919.989453119072 -1358.09135085 + 6881.425239177091 1475.328836214328 -1406.75750532 6944.452908297177 2051.195507667388 -1140.12984529 + 7325.852342027493 2105.321119781407 -1463.34405688 6512.08434843468 1896.350933679797 -1102.54951022 + 7031.572727504059 1774.693679319474 -1213.52003938 6900.09968913371 1166.638350572197 -1131.07251892 + 7181.702458615456 967.3715626867134 -1725.43287628 15446.42948966427 6774.292626715782 -1783.76426724 + 6833.165538098204 2146.90887196728 -1269.79940945 6961.133823287662 1984.991270176379 -1574.90784392 + 6278.009316875565 2160.268501707647 -1463.91000643 6375.481012012487 1444.193265154005 -1318.26096394 + 6416.155902527928 1938.412327275521 -1168.88275385 6915.097771970057 1755.535062391683 -1832.77813737 + 15869.83475373037 7733.671023234994 -1768.27292918 15561.77002257561 7305.163572556931 -1771.16490029 + 15746.81498876912 6592.70557226214 -1903.03071132 7038.123594209419 1614.849124078946 -1125.3249288 + 6821.855069844492 2242.925960274513 -1201.28226463 6897.958632811826 1367.633460976954 -1211.95736679 + 7012.453443632098 1450.78301709367 -1508.91300888 6679.579727551853 1287.729350424415 -1091.79629531 + 7337.705517591025 1885.842954230408 -1369.05710949 6391.411031127648 1578.65837112766 -1068.94433694 + 7370.613126134303 1639.495288861858 -1295.00453171 6734.914875700279 1628.506195531478 -1099.47810844 + 6252.52632653283 1708.733619249893 -1548.53817627 6650.453252625375 1856.883410116217 -1323.8429703 + 15804.13639802059 6826.230931620317 -1760.03319914 6785.394652048754 2107.240875565308 -1097.40244726 + 7332.999234005124 1567.320253544101 -1646.08221763 16020.71003904426 7134.495278261501 -1754.14124927 + 6491.811938440174 1576.943974336548 -1202.10716548 6399.577154464807 1627.881314403 -1132.46160907 + 6352.375655789807 1381.079828154743 -1252.38927247 + + + 6372.1967096 + + + 18207.275396 + + + + + + + 63 92 75 112 25 29 + 26 72 73 76 56 109 + 57 75 92 105 57 89 + 75 105 93 98 75 118 + 59 61 72 117 56 74 + 81 98 69 76 73 109 + 25 82 72 106 58 75 + 79 105 26 29 28 103 + 25 72 26 88 85 109 + 69 110 59 64 72 83 + 58 105 78 112 25 72 + 88 106 60 69 95 110 + 25 88 26 106 59 72 + 64 117 60 77 71 108 + 58 75 105 112 65 101 + 107 111 80 104 67 111 + 59 61 68 72 56 74 + 98 119 14 13 12 80 + 65 104 80 111 65 86 + 84 87 58 78 105 111 + 56 98 93 118 59 72 + 68 83 75 63 112 118 + 56 109 74 119 95 110 + 69 113 60 95 90 110 + 12 14 80 116 26 72 + 29 103 63 75 98 118 + 56 81 74 109 2 94 + 59 100 56 73 109 119 + 65 86 87 111 65 91 + 86 105 62 87 86 111 + 59 68 66 83 63 118 + 98 119 23 31 87 104 + 60 90 95 97 27 24 + 18 88 1 77 3 102 + 65 87 84 116 62 86 + 92 111 26 88 72 103 + 56 98 118 119 68 72 + 61 88 86 105 92 111 + 65 105 86 111 0 2 + 59 100 65 107 105 111 + 27 26 30 88 16 114 + 21 116 65 80 104 116 + 0 59 2 115 21 87 + 15 116 80 14 104 116 + 31 15 21 87 31 15 + 87 116 65 104 87 116 + 69 109 73 110 65 87 + 104 111 18 25 26 106 + 22 87 21 114 73 69 + 110 113 70 75 112 118 + 11 72 83 103 21 114 + 87 116 16 21 15 116 + 78 105 92 112 92 105 + 75 112 65 99 80 116 + 56 118 96 119 30 88 + 26 103 27 18 26 88 + 31 21 22 87 65 84 + 99 116 87 114 84 116 + 92 105 78 111 31 87 + 104 116 15 104 14 116 + 68 83 72 103 18 26 + 88 106 31 104 15 116 + 31 14 15 104 12 15 + 14 116 68 72 88 103 + 12 16 15 116 28 30 + 26 103 31 19 14 104 + 30 28 10 103 20 19 + 31 104 80 111 101 65 + 103 29 11 72 103 11 + 29 28 122 127 46 128 + 25 143 129 145 27 126 + 123 129 38 136 122 139 + 35 122 38 136 35 136 + 38 139 27 24 126 129 + 35 38 122 132 46 122 + 38 137 39 38 122 137 + 125 133 129 145 122 128 + 38 139 39 122 132 137 + 15 134 122 135 15 135 + 122 141 27 123 18 129 + 47 55 46 128 120 122 + 136 139 124 129 125 144 + 39 46 38 137 122 120 + 134 139 39 122 38 132 + 122 134 120 135 27 18 + 24 129 26 18 123 129 + 26 129 123 145 37 35 + 38 139 122 134 15 148 + 55 38 128 139 26 18 + 129 145 35 39 38 132 + 14 134 15 135 53 125 + 133 144 25 129 18 145 + 122 15 141 148 126 144 + 138 146 52 133 32 146 + 121 128 130 134 138 144 + 51 146 27 26 18 123 + 17 18 25 129 52 144 + 133 146 49 125 53 144 + 127 128 122 148 53 133 + 52 144 126 144 129 150 + 27 26 123 147 26 142 + 123 147 12 14 15 135 + 55 37 38 139 122 134 + 128 139 123 142 26 145 + 42 51 138 144 123 129 + 126 146 123 146 33 149 + 129 133 125 144 127 122 + 131 148 49 140 125 144 + 129 133 123 145 128 134 + 122 148 20 130 31 134 + 128 130 134 148 19 20 + 31 134 27 30 26 147 + 16 131 141 148 16 12 + 15 141 30 142 26 147 + 51 144 50 146 123 133 + 142 145 16 141 15 148 + 55 128 43 139 133 144 + 129 146 26 25 18 145 + 53 52 50 144 43 55 + 45 128 133 146 123 149 + 42 50 51 144 21 131 + 16 148 50 144 52 146 + 31 134 130 148 14 31 + 15 134 129 144 126 146 + 37 36 35 139 31 15 + 134 148 55 43 37 139 + 122 141 131 148 42 140 + 49 144 49 42 41 140 + 123 133 129 146 52 53 + 34 133 19 31 14 134 + 21 16 15 148 51 50 + 54 146 49 53 50 144 + 30 28 26 142 28 26 + 142 145 28 29 26 145 + 31 22 21 148 42 49 + 50 144 54 52 33 146 + 25 26 29 145 15 31 + 21 148 54 50 52 146 + 143 17 25 129 38 128 + 46 55 46 128 38 122 + 156 166 164 174 163 164 + 156 191 176 182 168 196 + 166 164 174 184 156 164 + 163 174 152 183 168 196 + 165 168 155 182 165 155 + 176 182 152 159 168 183 + 152 168 182 196 152 182 + 176 196 152 183 196 198 + 168 176 155 182 40 41 + 181 189 40 42 41 189 + 153 174 164 184 155 176 + 168 196 163 175 164 191 + 152 168 159 172 152 159 + 183 198 43 55 37 179 + 181 189 41 193 152 168 + 172 182 160 154 173 194 + 165 181 41 193 155 168 + 165 181 162 173 154 194 + 160 173 154 190 44 37 + 36 179 159 186 164 191 + 154 177 162 194 155 167 + 165 176 41 189 42 193 + 165 189 181 193 159 164 + 175 191 155 168 158 183 + 165 167 155 189 154 170 + 177 194 152 172 159 191 + 167 189 165 193 42 189 + 49 193 41 42 49 193 + 6 7 157 180 38 37 + 55 179 43 178 55 179 + 159 152 175 198 45 55 + 178 195 52 53 50 167 + 155 185 189 197 50 52 + 167 187 169 181 155 189 + 50 167 49 189 49 189 + 167 193 164 186 156 191 + 165 155 181 189 53 49 + 50 167 50 49 42 189 + 152 159 175 191 168 183 + 155 196 50 187 167 189 + 40 48 42 189 54 187 + 50 189 39 38 46 192 + 40 181 48 189 167 176 + 155 197 54 50 51 189 + 39 179 38 192 36 37 + 35 179 55 179 178 195 + 55 38 179 195 4 8 + 9 177 167 189 187 197 + 38 39 35 179 46 192 + 38 195 155 189 167 197 + 173 184 160 194 157 178 + 161 179 42 48 51 189 + 38 192 179 195 38 35 + 37 179 176 196 155 197 + 173 160 184 190 4 5 + 8 177 178 179 157 195 + 50 54 52 187 51 50 + 42 189 55 46 38 195 + 157 179 171 192 179 192 + 157 195 55 47 46 195 + 190 151 184 173 190 184 + 151 174 172 191 188 159 + 179 43 44 37 + + + 4 8 12 16 20 24 + 28 32 36 40 44 48 + 52 56 60 64 68 72 + 76 80 84 88 92 96 + 100 104 108 112 116 120 + 124 128 132 136 140 144 + 148 152 156 160 164 168 + 172 176 180 184 188 192 + 196 200 204 208 212 216 + 220 224 228 232 236 240 + 244 248 252 256 260 264 + 268 272 276 280 284 288 + 292 296 300 304 308 312 + 316 320 324 328 332 336 + 340 344 348 352 356 360 + 364 368 372 376 380 384 + 388 392 396 400 404 408 + 412 416 420 424 428 432 + 436 440 444 448 452 456 + 460 464 468 472 476 480 + 484 488 492 496 500 504 + 508 512 516 520 524 528 + 532 536 540 544 548 552 + 556 560 564 568 572 576 + 580 584 588 592 596 600 + 604 608 612 616 620 624 + 628 632 636 640 644 648 + 652 656 660 664 668 672 + 676 680 684 688 692 696 + 700 704 708 712 716 720 + 724 728 732 736 740 744 + 748 752 756 760 764 768 + 772 776 780 784 788 792 + 796 800 804 808 812 816 + 820 824 828 832 836 840 + 844 848 852 856 860 864 + 868 872 876 880 884 888 + 892 896 900 904 908 912 + 916 920 924 928 932 936 + 940 944 948 952 956 960 + 964 968 972 976 980 984 + 988 992 996 1000 1004 1008 + 1012 1016 1020 1024 1028 1032 + 1036 1040 1044 1048 1052 1056 + 1060 1064 1068 1072 1076 1080 + 1084 1088 1092 1096 1100 1104 + 1108 1112 1116 1120 1124 1128 + 1132 1136 1140 1144 1148 1152 + 1156 1160 1164 1168 1172 1176 + 1180 1184 1188 1192 1196 1200 + 1204 1208 1212 1216 + + + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 + + + + + diff --git a/inputFiles/coverageSmokes/synthetic_thermal_coverage.vtu b/inputFiles/coverageSmokes/synthetic_thermal_coverage.vtu new file mode 100644 index 00000000000..22a4b7267e2 --- /dev/null +++ b/inputFiles/coverageSmokes/synthetic_thermal_coverage.vtu @@ -0,0 +1,505 @@ + + + + + + + + + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 1 1 1 1 1 1 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 2 + 2 2 2 2 2 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 3 3 3 + 3 3 3 + + + 520 975 1836 1932 2098 3376 + 3644 4178 5039 5192 5411 6072 + 6696 7676 8618 8935 9690 11258 + 12212 12338 12766 12842 13299 13918 + 14854 15324 15950 15991 16257 16289 + 16572 16930 17104 18026 18291 18771 + 19030 19412 20099 20306 21006 21295 + 22093 22562 22601 22629 22725 23431 + 23562 23710 24402 26402 26751 26785 + 26802 27381 27440 27573 27600 27861 + 28174 28354 29093 29261 29886 30264 + 30716 30898 31017 31473 31625 31676 + 31715 32195 32254 32320 32428 32588 + 32843 33290 33820 34400 35070 35357 + 35479 36099 36291 37005 37140 37160 + 37197 37869 38623 38677 39076 40861 + 42392 43732 44198 44321 44687 44732 + 45067 45283 46337 46935 47772 48574 + 48706 48916 49106 49174 50187 50378 + 50541 50724 51424 51847 52177 52846 + 54868 55469 55564 55825 56090 56091 + 56865 57191 57469 57661 57726 57991 + 58250 60085 61415 61551 62037 62324 + 63092 64707 64819 65497 65499 66206 + 67103 67260 67467 67560 67936 68207 + 69563 70156 70174 70457 70976 71131 + 71311 71647 72638 73005 73009 73570 + 73900 74090 74171 74385 74526 75396 + 76045 76166 77438 77672 77988 78084 + 78161 78263 78406 78448 78540 78686 + 79661 80009 80765 81987 82145 82812 + 82882 83831 85522 86220 86759 87284 + 87925 89260 90466 90467 93090 93909 + 94722 95217 95290 95854 96760 96843 + 97566 98053 98846 99298 99545 99931 + 100093 100618 101653 102032 103387 103400 + 103790 104165 105077 105870 106107 108215 + 108218 108537 109348 109715 110012 110348 + 110599 110739 111625 111654 111682 111689 + 111808 112143 112451 113305 113868 114381 + 114605 114784 114794 115094 115816 116958 + 117422 117497 117588 117853 118299 119386 + 121216 121449 122738 122982 123281 123881 + 124521 124548 124604 125261 126093 126705 + 126821 127594 127715 128439 128468 128572 + 128599 129581 131265 131604 132496 132677 + 133770 133771 135335 + + + + + 4965.035032908142 2264.617872445472 -500 4239.623819534134 3176.757827165824 -500 + 16106.88076660959 7183.896108400462 -500 5044.746611305187 3128.0676533446 -500 + 15268.22134232392 7400.008845890813 -500 4621.861658340067 2088.00322509168 -2000 + 14928.25639073351 7372.64062876512 -2000 15927.66025517862 7191.62096586634 -2000 + 4424.939030918991 3337.98909972347 -2000 4910.799373610424 2801.438541614166 -2000 + 4854.935623733791 2360.778028641292 -796.753607987 5027.030973577464 2490.707181355228 -800.695073783 + 4824.372786902584 2654.83291518825 -796.168541165 15425.12484090007 7309.49984638848 -1557.25766899 + 15433.53351780857 7550.393954079803 -1557.32988096 4538.908320929658 2735.994878495637 -792.453857678 + 4585.770140659286 2442.022734212771 -792.840201573 15654.61183291513 7428.327763482946 -1558.16154905 + 15688.90367429904 7219.800498002436 -1558.10101004 15541.85486219347 7039.013576270028 -1557.99751826 + 4352.849201125719 2577.930554330891 -791.832178832 15827.97292406456 7578.191430109337 -1557.3110404 + 16018.31309188429 7506.424857686015 -1554.75842912 5180.696022101536 2982.383337143748 -805.33603948 + 15872.34349239403 6985.663840996416 -1556.87089382 16155.27732517789 7073.743355157525 -1551.81837832 + 16231.8056830548 7376.564401063879 -1549.75744275 4930.847681726383 2947.193353553401 -798.35686337 + 4755.434803995762 3141.428332389566 -794.978717806 4693.229255967135 2917.204831717634 -794.062341692 + 5103.749889229026 2719.124790403613 -802.870636219 4443.519939434904 3048.430669923569 -791.950226558 + 15922.53773134394 7313.225873204623 -1556.26057398 5027.030971339942 2490.707181337909 -950.695073724 + 4824.372784553089 2654.832915040218 -946.168541122 15425.1248377579 7309.499845142172 -1707.25766897 + 15433.53351446838 7550.393952604836 -1707.32988093 4538.908318000885 2735.994878539103 -942.453857657 + 4585.770137630408 2442.02273425241 -942.840201545 15654.61182936576 7428.327761902474 -1708.16154905 + 15688.90367092147 7219.80049652948 -1708.10101005 15541.85485910991 7039.013575056141 -1707.99751825 + 15827.97292024998 7578.191428349804 -1707.31104043 15633.98146066355 7650.96960359091 -1708.17291011 + 16018.31308777003 7506.424855697938 -1704.7584292 5180.696020758819 2982.383335988904 -955.336039434 + 15872.34348895454 6985.66383940205 -1706.87089386 16155.27732113549 7073.743353097027 -1701.81837843 + 4930.847679898498 2947.1933528748 -948.356863329 4755.434801783967 3141.428331779618 -944.978717771 + 4693.229253610685 2917.204831456121 -944.062341661 5103.74988764245 2719.124789976916 -952.870636171 + 4443.519936573133 3048.430670019949 -941.950226549 15922.53772754576 7313.225871418299 -1706.26057403 + 15631.7521007776 7539.302538322157 -1198.25596637 15588.35129136617 6679.158504323897 -1357.64658739 + 15643.63246283868 7770.830822385116 -1391.68963049 15461.62201165479 7001.667271148624 -864.896569104 + 4428.166920219104 2704.268309130888 -640.791184835 16159.80318500536 6996.914529080244 -1435.92019943 + 16228.21040849824 6991.495037977197 -1268.79449181 4917.943991933625 2031.751442316643 -674.967832164 + 15567.06969935776 7253.434391288517 -1460.0730734 15971.78521805879 7641.868965532365 -1472.46452965 + 15380.93286508278 7421.057931377661 -1028.68522669 5208.658525969562 2879.504681475467 -688.374547851 + 16128.19486994485 7728.690277970626 -1301.23409517 16495.07276194134 7668.692740324926 -780.130497947 + 16208.98098672995 7381.992356408715 -1095.99593586 4505.064439706515 3196.091156712393 -705.650182757 + 15688.0062973088 6650.509640038711 -1190.50503072 15661.8857759854 7292.545500402554 -1324.2522263 + 15714.04163206046 8081.51161518462 -1099.43174016 15822.97310683744 6826.312815714578 -689.795592256 + 16067.62006794119 7457.920527276538 -1399.39500644 4797.658194608125 2496.648630791328 -719.654216036 + 15254.79904381521 7369.514562716516 -1395.60362123 15552.46433066739 7585.777078286873 -1497.68147388 + 15150.51319270264 7083.206093482272 -1141.38821205 15509.0312886289 6894.254235761543 -1496.35823024 + 14941.1054517046 6549.430169952467 -1048.0009123 15710.81882013538 6838.963962032019 -1432.12004984 + 4224.124366521088 2674.120262010353 -726.006039536 15932.37229415238 7138.95984656683 -1494.58962605 + 15207.78413831427 7044.66428168552 -1334.71402278 15954.36992499789 6572.620671423286 -948.841834276 + 15286.67888392361 6976.901421598507 -1428.45591986 15913.58235195537 7041.503906388943 -1376.22532424 + 15092.63384389589 7504.08129636816 -1235.42220432 16034.86254689591 7715.727590828663 -907.924185739 + 16180.45901368084 7480.715669306353 -1223.49432616 16397.69851362052 6909.665773308187 -852.65886241 + 15619.59167894527 6905.716645096024 -1264.24351644 15303.30030318709 7256.449051501891 -1505.82923813 + 15676.78607514222 7837.556830215353 -1451.01874439 15882.39247652947 7943.670198123707 -678.778784493 + 15861.60797771494 7450.709907998588 -1510.73791507 15668.9110525658 7336.720245216547 -1400.74597627 + 15423.96605542521 7569.822675340642 -1436.88438418 15247.75688072624 7649.314628467463 -789.476409662 + 15735.63664181413 6982.5989554007 -1110.98954118 15942.68793236336 7073.363581380735 -1017.16449274 + 15919.85778659095 7319.615563004485 -1446.02692966 16075.59095682218 7341.782373474422 -1337.86553015 + 15940.48859618625 7732.449457341471 -1026.37991058 15836.59086224634 6820.897632889554 -1510.35098416 + 4866.240980360214 2822.202778660051 -746.013094152 15629.08549224558 7218.108221210961 -1509.57674085 + 15782.237990741 7524.326609718499 -1267.17083606 4454.168411227873 2468.905674602678 -746.463351659 + 5214.327154006901 2420.596253121998 -761.343798459 16047.74609068493 7016.730328775448 -1196.55247288 + 15522.081641043 7685.15804370933 -1633.17291005 16143.14836261805 7684.611120512637 -1625.42623808 + 5113.119218155823 2650.776221203797 -878.01300148 4438.502098004604 2827.054569494013 -866.876904924 + 15669.0919484872 7206.668016394629 -1633.10101004 4932.68393626033 3091.933201311761 -867.310283501 + 16068.11177085479 6983.40062545337 -1635.81286343 4842.861556866336 2420.7882716663 -854.299500285 + 15969.73666844154 7398.189049176358 -1651.19017377 4774.989413562239 2790.891114275366 -892.076153195 + 16106.6442347609 7366.151948930444 -1602.07496845 15777.08261067931 6849.039018720965 -1607.73717514 + 4626.315198216476 3116.441152568739 -894.967245743 15363.38775905862 7071.156494753336 -1653.12327022 + 15737.8287806317 7489.791276979209 -1606.76737479 15404.35700540844 7430.187248715652 -1604.68064833 + 15363.63305672098 7451.408446755489 -1660.38135891 15751.20511985038 6786.58795617967 -1659.96683382 + 4895.692949037504 2788.187451423158 -843.782989232 4783.888260069965 2471.042768079352 -901.889718059 + 5137.013252143095 3099.127467516122 -907.966421585 4655.474897541904 3020.152471776383 -841.433431038 + 15653.6550295367 7540.609603105589 -1666.8269957 4607.109415101526 2622.068933021229 -837.64935387 + 15462.0840697585 7029.215372842845 -1607.80299279 15878.83588765235 7144.556324795994 -1598.31555276 + 4497.423854589558 2494.704663571886 -900.312088875 5090.193000199215 2517.755364875075 -841.318200864 + 4680.142194418026 2247.799765940356 -1104.03815646 5180.62002465031 2840.372048415013 -1426.38393825 + 4711.565458381153 3012.112225486221 -1058.73660069 5210.713403346416 3071.074390369278 -1225.76020641 + 5103.910093366013 3315.942235574666 -1623.55757529 15890.16254027279 7160.562769208526 -1827.35651757 + 4325.456363519454 2813.044200010701 -1239.70846503 4912.962995930453 2352.69126479524 -1772.92335146 + 4331.983469469798 2765.452757463227 -1127.14468242 4975.552645516593 2809.061832523604 -1143.12621502 + 4747.959624655766 3233.903210469751 -1325.46932153 15415.23591294995 7689.268269266133 -1797.6598104 + 4285.754067726776 2922.58189451297 -1016.52850313 4651.712507124432 2557.29906919196 -1017.80914255 + 4303.078058492369 3126.302364182408 -1437.45367321 4199.853775008943 3387.278009362184 -1810.86927332 + 5220.643360624215 2443.350086703095 -1211.71553476 4204.603099199282 2796.366310848328 -1669.61260111 + 4619.843173551247 2441.254518502499 -1187.11682689 5083.277609415716 2670.142739337042 -1304.19165914 + 15446.42948966427 6774.292626715782 -1783.76426724 4282.706004601011 2751.798665379018 -1340.74135634 + 5072.725087103064 2577.950964907845 -1009.66352762 4724.269344933862 2240.577313565794 -1366.45485389 + 4308.751283514604 2459.953969418568 -995.285011106 4759.396877877594 2245.130263936703 -995.704891418 + 4318.802333840854 3308.788765154862 -1565.26339506 15869.83475373037 7733.671023234994 -1768.27292918 + 15561.77002257561 7305.163572556931 -1771.16490029 4863.779019897612 2609.252823507924 -1076.14901471 + 15746.81498876912 6592.70557226214 -1903.03071132 4625.517328255863 2531.499860370648 -1487.01775615 + 4907.328358698085 3403.617876850702 -1482.8225864 4768.523540012982 2922.213103096319 -1001.95196947 + 4719.472838927672 3034.171181326662 -1201.65544467 5080.581172948033 3004.965604640774 -1089.13768028 + 4439.836328388086 2718.112629755352 -1062.87981432 4072.909365349799 2432.266693825141 -1855.97606747 + 5394.324870014113 2946.503029414001 -1532.18029669 4472.011585418446 3192.374940973824 -989.446448706 + 4649.604395435363 3055.594646981211 -1122.43312308 5328.251249968101 2991.314982693548 -1864.61390066 + 15804.13639802059 6826.230931620317 -1760.03319914 4483.819662547135 2360.183722401803 -1056.07107237 + 4269.158182149175 2344.506364523959 -1231.0681833 4841.880390204386 2656.809356025562 -1242.06865008 + 4735.662909395504 2744.623240825328 -1588.61237499 4995.773918247291 2850.454100091352 -1039.92636722 + 16020.71003904426 7134.495278261501 -1754.14124927 4787.955751426627 2782.543484338947 -1370.59832268 + 5283.356206276125 2240.903612790237 -1335.14721689 4515.784977979964 2489.68483577903 -1290.12356728 + + + 5023.7387023 + + + 18207.275396 + + + + + + + 60 87 71 103 28 31 + 29 106 68 72 54 100 + 55 71 87 97 29 31 + 15 106 55 84 71 97 + 88 92 71 108 31 82 + 15 106 54 70 78 92 + 64 72 68 100 56 71 + 76 97 80 100 64 101 + 56 97 74 103 57 64 + 89 101 57 73 67 99 + 56 71 97 103 62 94 + 98 102 77 96 63 102 + 54 70 92 111 17 14 + 13 77 62 96 77 102 + 62 81 79 83 56 74 + 97 102 54 92 88 108 + 58 65 61 75 71 60 + 103 108 54 100 70 111 + 89 101 64 104 10 11 + 12 75 57 89 85 101 + 13 17 77 107 60 71 + 92 108 54 78 70 100 + 54 68 100 111 62 81 + 83 102 65 75 69 106 + 62 86 81 97 59 83 + 81 102 60 108 92 111 + 31 69 82 106 26 32 + 83 96 57 85 89 91 + 2 73 4 95 62 83 + 79 107 15 106 82 109 + 59 81 87 102 54 92 + 108 111 81 97 87 102 + 62 97 81 102 15 16 + 12 109 11 12 75 110 + 62 98 97 102 19 105 + 24 107 62 77 96 107 + 75 82 69 106 24 83 + 18 107 3 0 58 65 + 77 17 96 107 32 18 + 24 83 32 18 83 107 + 62 96 83 107 64 100 + 68 101 62 83 96 102 + 25 83 24 105 68 64 + 101 104 66 71 103 108 + 24 105 83 107 15 12 + 106 109 19 24 18 107 + 74 97 87 103 87 97 + 71 103 62 93 77 107 + 54 108 90 111 82 106 + 75 109 32 24 25 83 + 62 79 93 107 83 105 + 79 107 58 69 65 75 + 87 97 74 102 32 83 + 96 107 16 75 12 109 + 18 96 17 107 12 106 + 75 110 32 96 18 107 + 32 17 18 96 13 18 + 17 107 27 28 29 106 + 13 19 18 107 12 30 + 27 106 75 106 12 109 + 12 29 15 106 32 21 + 17 96 22 21 32 96 + 3 0 1 58 27 29 + 12 106 77 102 94 62 + 116 118 46 120 40 128 + 116 134 35 116 40 128 + 35 128 40 134 33 114 + 34 131 35 40 116 125 + 46 116 40 129 41 40 + 116 129 34 114 51 121 + 116 120 40 134 41 116 + 125 129 48 124 121 132 + 114 121 117 132 18 126 + 116 127 114 121 34 131 + 115 121 37 124 18 127 + 116 136 47 53 46 120 + 52 115 37 124 112 116 + 128 134 41 46 40 129 + 116 112 126 134 41 116 + 40 125 116 126 112 127 + 48 34 51 121 39 35 + 40 134 48 50 121 124 + 131 135 121 138 37 121 + 50 124 37 121 115 138 + 116 126 18 137 53 40 + 120 134 15 115 133 135 + 119 135 131 138 35 41 + 40 125 121 131 119 135 + 17 126 18 127 116 18 + 136 137 113 120 122 126 + 115 121 135 138 117 121 + 114 130 52 37 50 124 + 119 121 114 131 23 27 + 117 130 118 120 116 137 + 114 121 119 130 11 119 + 12 139 13 17 18 127 + 119 130 121 135 130 133 + 121 135 115 121 133 135 + 53 39 40 134 116 126 + 120 134 117 130 27 133 + 119 130 12 139 37 131 + 121 138 118 116 123 137 + 120 126 116 137 22 122 + 32 126 120 122 126 137 + 21 22 32 126 10 12 + 119 135 19 123 136 137 + 19 13 18 136 121 124 + 115 133 29 133 130 135 + 12 130 119 135 19 136 + 18 137 53 120 42 134 + 31 29 15 133 34 121 + 37 131 29 15 133 135 + 121 130 117 133 42 53 + 44 120 114 130 119 139 + 29 130 12 135 24 123 + 19 137 32 126 122 137 + 17 32 18 126 37 38 + 131 138 39 36 35 134 + 32 18 126 137 53 42 + 39 134 116 136 123 137 + 10 16 12 135 50 34 + 48 121 34 37 38 131 + 21 32 17 126 24 19 + 18 137 50 37 34 121 + 29 27 130 133 29 12 + 15 135 29 27 12 130 + 16 20 15 135 32 25 + 24 137 27 30 12 130 + 15 12 16 135 18 32 + 24 137 40 120 46 53 + 46 120 40 116 30 139 + 12 130 140 149 148 169 + 37 153 152 173 148 149 + 140 158 150 143 174 185 + 146 150 174 185 144 157 + 147 186 37 173 152 179 + 153 162 34 165 42 53 + 39 168 149 174 148 180 + 140 169 148 176 148 169 + 149 180 146 150 185 191 + 150 159 143 185 149 158 + 148 174 48 45 51 173 + 37 153 38 164 43 39 + 36 168 51 162 34 173 + 34 162 153 173 148 176 + 169 180 34 153 37 173 + 37 34 38 153 150 159 + 185 191 143 149 174 185 + 152 173 153 176 9 8 + 177 181 142 169 176 180 + 156 158 149 185 163 141 + 189 190 141 171 163 189 + 152 142 173 176 6 7 + 145 170 40 39 53 168 + 42 167 53 168 150 161 + 154 189 147 177 155 181 + 5 9 177 181 44 53 + 167 188 48 51 34 173 + 34 37 50 173 141 154 + 171 189 158 174 149 185 + 141 171 154 172 159 150 + 189 191 153 173 142 176 + 142 175 169 180 166 172 + 171 186 172 178 171 186 + 142 153 169 187 169 175 + 149 180 142 173 153 187 + 161 189 150 191 142 169 + 153 176 162 169 153 187 + 41 40 46 182 162 153 + 173 187 41 168 40 182 + 36 39 35 168 53 168 + 167 188 158 146 174 185 + 158 184 146 185 53 40 + 168 188 48 34 50 173 + 153 176 169 183 159 189 + 163 191 40 41 35 168 + 46 182 40 188 142 169 + 175 187 145 167 151 168 + 50 49 48 173 40 182 + 168 188 40 35 39 168 + 163 189 161 191 167 168 + 145 188 53 46 40 188 + 145 168 160 182 168 182 + 145 188 53 47 46 188 + 179 50 37 173 179 37 + 50 52 168 42 43 39 + + + 4 8 12 16 20 24 + 28 32 36 40 44 48 + 52 56 60 64 68 72 + 76 80 84 88 92 96 + 100 104 108 112 116 120 + 124 128 132 136 140 144 + 148 152 156 160 164 168 + 172 176 180 184 188 192 + 196 200 204 208 212 216 + 220 224 228 232 236 240 + 244 248 252 256 260 264 + 268 272 276 280 284 288 + 292 296 300 304 308 312 + 316 320 324 328 332 336 + 340 344 348 352 356 360 + 364 368 372 376 380 384 + 388 392 396 400 404 408 + 412 416 420 424 428 432 + 436 440 444 448 452 456 + 460 464 468 472 476 480 + 484 488 492 496 500 504 + 508 512 516 520 524 528 + 532 536 540 544 548 552 + 556 560 564 568 572 576 + 580 584 588 592 596 600 + 604 608 612 616 620 624 + 628 632 636 640 644 648 + 652 656 660 664 668 672 + 676 680 684 688 692 696 + 700 704 708 712 716 720 + 724 728 732 736 740 744 + 748 752 756 760 764 768 + 772 776 780 784 788 792 + 796 800 804 808 812 816 + 820 824 828 832 836 840 + 844 848 852 856 860 864 + 868 872 876 880 884 888 + 892 896 900 904 908 912 + 916 920 924 928 932 936 + 940 944 948 952 956 960 + 964 968 972 976 980 984 + 988 992 996 1000 1004 1008 + 1012 1016 1020 1024 1028 1032 + 1036 1040 1044 1048 1052 1056 + 1060 1064 1068 1072 1076 1080 + 1084 1088 1092 1096 1100 1104 + 1108 1112 1116 + + + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 10 10 10 + 10 10 10 + + + + + diff --git a/inputFiles/multiscalePreconditioner/cube_coverage_smoke.xml b/inputFiles/multiscalePreconditioner/cube_coverage_smoke.xml new file mode 100644 index 00000000000..68bf39931e7 --- /dev/null +++ b/inputFiles/multiscalePreconditioner/cube_coverage_smoke.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/inputFiles/wavePropagation/acous3D_dg_order2_coverage.xml b/inputFiles/wavePropagation/acous3D_dg_order2_coverage.xml new file mode 100644 index 00000000000..0ce33323759 --- /dev/null +++ b/inputFiles/wavePropagation/acous3D_dg_order2_coverage.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inputFiles/wavePropagation/acous3D_dg_order3_overlay.xml b/inputFiles/wavePropagation/acous3D_dg_order3_overlay.xml new file mode 100644 index 00000000000..f50c3ae6a86 --- /dev/null +++ b/inputFiles/wavePropagation/acous3D_dg_order3_overlay.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/inputFiles/wavePropagation/acous3D_dg_setup_only_overlay.xml b/inputFiles/wavePropagation/acous3D_dg_setup_only_overlay.xml new file mode 100644 index 00000000000..b360f5fe23f --- /dev/null +++ b/inputFiles/wavePropagation/acous3D_dg_setup_only_overlay.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/scripts/check_coverage_thresholds.py b/scripts/check_coverage_thresholds.py new file mode 100755 index 00000000000..5d95baf09c7 --- /dev/null +++ b/scripts/check_coverage_thresholds.py @@ -0,0 +1,869 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +"""Validate and enforce repository-owned LLVM coverage thresholds.""" + +import argparse +import hashlib +import html +import json +import math +import pathlib +import re +import sys + + +COVERAGE_SUMMARY_SCHEMA_VERSION = 3 +COVERAGE_POLICY_SCHEMA_VERSION = 1 +COVERAGE_CONTRACT_ID = "geos-llvm-source-coverage-v1" +CANONICAL_METRICS = ( "regions", "functions", "lines", "branches" ) +DISPLAY_METRICS = ( "lines", "functions", "branches", "regions" ) +SUPPLEMENTAL_METRICS = ( + ( "native_branch_outcomes", "Native branch outcomes" ), +) +if set( DISPLAY_METRICS ) != set( CANONICAL_METRICS ): + raise RuntimeError( "display metrics must match canonical coverage metrics" ) + +METRIC_SEMANTICS = { + "canonical_metrics": "llvm-cov-summary-instantiation-groups-v1", + "native_branch_outcomes": "llvm-cov-lcov-emitted-brda-records-v1", + "source_selection": "llvm-cov-sources-and-ignore-regex-v1", +} +OBJECT_SELECTION = { + "contract_version": 1, + "primary_object": "bin/geosx", + "additional_object_directory": "lib", + "additional_object_globs": [ "*.dll", "*.dylib", "*.so", "*.so.*" ], + "excluded_library_basenames": [ "libgtest*", "libtestingUtilities.*" ], + "required_object_basenames": [ "geosx", "libmainInterface" ], + "coverage_mapping_section": "__llvm_covmap", + "test_executables": "excluded", +} +MEASUREMENT_KEYS = { + "commit_sha", + "tree_sha", + "contract_id", + "contract_fingerprint", + "container", + "toolchain", + "build_config", + "metric_semantics", + "object_selection", +} +CMAKE_FIXED_CONFIG_NAMES = { + "BLT_CXX_STD", + "BUILD_SHARED_LIBS", + "CMAKE_BUILD_TYPE", + "CMAKE_CUDA_ARCHITECTURES", + "CMAKE_C_EXTENSIONS", + "CMAKE_C_STANDARD", + "CMAKE_C_STANDARD_REQUIRED", + "CMAKE_CXX_EXTENSIONS", + "CMAKE_CXX_STANDARD", + "CMAKE_CXX_STANDARD_REQUIRED", + "CMAKE_INTERPROCEDURAL_OPTIMIZATION", + "CMAKE_POSITION_INDEPENDENT_CODE", + "CMAKE_UNITY_BUILD", + "GEOS_GLOBALINDEX_TYPE", + "GEOS_GLOBALINDEX_TYPE_FLAG", + "GEOS_LA_INTERFACE", + "GEOS_LA_INTERFACE_HYPRE", + "GEOS_LOCALINDEX_TYPE", + "GEOS_LOCALINDEX_TYPE_FLAG", +} +CMAKE_REQUIRED_CONFIG_NAMES = { + "BLT_CXX_STD", + "BUILD_SHARED_LIBS", + "CMAKE_BUILD_TYPE", + "ENABLE_COVERAGE", + "ENABLE_CUDA", + "ENABLE_HIP", + "ENABLE_HYPRE", + "ENABLE_MPI", + "ENABLE_OPENMP", + "ENABLE_TRILINOS", + "GEOS_BUILD_SHARED_LIBS", + "GEOS_ENABLE_BOUNDS_CHECK", + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE", + "GEOS_GLOBALINDEX_TYPE", + "GEOS_LA_INTERFACE", + "GEOS_LOCALINDEX_TYPE", + "LVARRAY_BOUNDS_CHECK", + "RAJA_ENABLE_CUDA", + "RAJA_ENABLE_HIP", + "RAJA_ENABLE_OPENMP", +} + + +def require_schema_version( + document: dict, description: str, expected_version: int +) -> None: + version = document.get( "schema_version" ) + if ( + isinstance( version, bool ) + or not isinstance( version, int ) + or version != expected_version + ): + raise ValueError( + f"{description} schema_version must be the integer {expected_version}" + ) + + +def load_json( path: pathlib.Path ) -> dict: + try: + with path.open( encoding="utf-8" ) as stream: + document = json.load( stream ) + except ( OSError, json.JSONDecodeError ) as error: + raise ValueError( f"cannot read {path}: {error}" ) from error + if not isinstance( document, dict ): + raise ValueError( f"{path} must contain a JSON object" ) + return document + + +def require_integer( value, description: str, minimum: int = 0 ) -> int: + if isinstance( value, bool ) or not isinstance( value, int ) or value < minimum: + raise ValueError( f"{description} must be an integer >= {minimum}" ) + return value + + +def require_exact_keys( value: dict, expected: set[str], description: str ) -> None: + actual = set( value ) + if actual != expected: + missing = sorted( expected - actual ) + unexpected = sorted( actual - expected ) + details = [] + if missing: + details.append( "missing " + ", ".join( missing ) ) + if unexpected: + details.append( "unexpected " + ", ".join( unexpected ) ) + raise ValueError( f"{description} fields are invalid: {'; '.join( details )}" ) + + +def require_string( value: object, description: str, allow_newlines=False ) -> str: + if not isinstance( value, str ) or not value: + raise ValueError( f"{description} must be a nonempty string" ) + forbidden = "\0" if allow_newlines else "\0\r\n" + if any( character in value for character in forbidden ): + raise ValueError( f"{description} contains a control character" ) + if value != value.rstrip(): + raise ValueError( f"{description} must not have trailing whitespace" ) + return value + + +def validate_repo_path( value: object, description: str ) -> str: + path = require_string( value, description ) + normalized = pathlib.PurePosixPath( path ) + if ( + normalized.is_absolute() + or normalized.as_posix() != path + or ".." in normalized.parts + or "\\" in path + ): + raise ValueError( f"{description} must be a normalized repository-relative path" ) + return path + + +def validate_scoped_path( value: object, scope: str, description: str ) -> str: + path = validate_repo_path( value, description ) + scope_prefix = f"{scope}/" + if not path.startswith( scope_prefix ) or len( path ) == len( scope_prefix ): + raise ValueError( f"{description} is outside the scope" ) + return path + + +def validate_metric( + metric: object, + description: str, + minimum_total: int = 1, + allowed_extra_fields: set[str] | None = None, +) -> dict: + if not isinstance( metric, dict ): + raise ValueError( f"{description} must be an object" ) + expected_fields = { "covered", "total", "not_covered", "percent" } + if allowed_extra_fields: + expected_fields.update( allowed_extra_fields ) + require_exact_keys( metric, expected_fields, description ) + + covered = require_integer( metric.get( "covered" ), f"{description}.covered" ) + total = require_integer( + metric.get( "total" ), f"{description}.total", minimum_total + ) + not_covered = require_integer( + metric.get( "not_covered" ), f"{description}.not_covered" + ) + if covered > total: + raise ValueError( f"{description}.covered cannot exceed total" ) + if not_covered != total - covered: + raise ValueError( f"{description}.not_covered is inconsistent" ) + + percent = metric.get( "percent" ) + if isinstance( percent, bool ) or not isinstance( percent, ( int, float ) ): + raise ValueError( f"{description}.percent must be numeric" ) + if not math.isfinite( percent ): + raise ValueError( f"{description}.percent must be finite" ) + expected_percent = round( 100.0 * covered / total if total else 0.0, 6 ) + if abs( float( percent ) - expected_percent ) > 1.0e-6: + raise ValueError( f"{description}.percent is inconsistent with its counts" ) + + return { + "covered": covered, + "total": total, + "not_covered": not_covered, + "percent": expected_percent, + } + + +def validate_container( raw_container: object ) -> dict: + if not isinstance( raw_container, dict ): + raise ValueError( "measurement.container must be an object" ) + require_exact_keys( + raw_container, + { "image", "image_id", "image_digests" }, + "measurement.container", + ) + image = require_string( raw_container.get( "image" ), "measurement.container.image" ) + if any( character.isspace() for character in image ): + raise ValueError( "measurement.container.image must be a container reference" ) + image_id = require_string( + raw_container.get( "image_id" ), "measurement.container.image_id" + ) + if re.fullmatch( r"sha256:[0-9a-f]{64}", image_id ) is None: + raise ValueError( "measurement.container.image_id must be an immutable SHA256 ID" ) + + raw_digests = raw_container.get( "image_digests" ) + if not isinstance( raw_digests, list ) or not raw_digests: + raise ValueError( + "measurement.container.image_digests must be a nonempty array" + ) + digests = [] + for index, raw_digest in enumerate( raw_digests ): + digest = require_string( + raw_digest, f"measurement.container.image_digests[{index}]" + ) + if re.fullmatch( r"[^\s@]+@sha256:[0-9a-f]{64}", digest ) is None: + raise ValueError( + "measurement.container.image_digests entries must be immutable SHA256 digests" + ) + digests.append( digest ) + if digests != sorted( set( digests ) ): + raise ValueError( + "measurement.container.image_digests must be sorted and unique" + ) + return { "image": image, "image_id": image_id, "image_digests": digests } + + +def tool_major_from_version( version: str, description: str ) -> int: + match = re.search( r"\bversion\s+([0-9]+)(?:[.\s]|$)", version ) + if match is None: + raise ValueError( f"{description} does not contain a version number" ) + return int( match.group( 1 ) ) + + +def validate_toolchain( raw_toolchain: object, expected_major: int ) -> dict: + if not isinstance( raw_toolchain, dict ): + raise ValueError( "measurement.toolchain must be an object" ) + keys = { + "c_compiler_version", + "cxx_compiler_version", + "llvm_cov_version", + "llvm_package_versions", + "compiler_target", + } + require_exact_keys( raw_toolchain, keys, "measurement.toolchain" ) + string_keys = keys - { "llvm_package_versions" } + toolchain = { + name: require_string( + raw_toolchain.get( name ), + f"measurement.toolchain.{name}", + allow_newlines=name.endswith( "_version" ), + ) + for name in string_keys + } + raw_packages = raw_toolchain.get( "llvm_package_versions" ) + if not isinstance( raw_packages, list ) or not raw_packages: + raise ValueError( + "measurement.toolchain.llvm_package_versions must be a nonempty array" + ) + packages = [] + for index, raw_package in enumerate( raw_packages ): + package = require_string( + raw_package, + f"measurement.toolchain.llvm_package_versions[{index}]", + ) + if re.fullmatch( r"[A-Za-z0-9.+-]+=[^\s=]+", package ) is None: + raise ValueError( + "measurement.toolchain.llvm_package_versions entries must be package=version" + ) + packages.append( package ) + if packages != sorted( set( packages ) ): + raise ValueError( + "measurement.toolchain.llvm_package_versions must be sorted and unique" + ) + toolchain["llvm_package_versions"] = packages + target = toolchain["compiler_target"] + if any( character.isspace() for character in target ): + raise ValueError( "measurement.toolchain.compiler_target is invalid" ) + for name in ( + "c_compiler_version", + "cxx_compiler_version", + "llvm_cov_version", + ): + if tool_major_from_version( + toolchain[name], f"measurement.toolchain.{name}" + ) != expected_major: + raise ValueError( + f"measurement.toolchain.{name} does not match tool.major" + ) + return { name: toolchain[name] for name in sorted( toolchain ) } + + +def validate_build_config( raw_config: object ) -> dict: + if not isinstance( raw_config, dict ) or not raw_config: + raise ValueError( "measurement.build_config must be a nonempty object" ) + build_type = raw_config.get( "CMAKE_BUILD_TYPE" ) + if not isinstance( build_type, str ) or not build_type: + raise ValueError( + "measurement.build_config.CMAKE_BUILD_TYPE must be a nonempty string" + ) + build_type_suffix = re.sub( r"[^A-Za-z0-9]", "_", build_type ).upper() + active_flag_names = { + "CMAKE_C_FLAGS", + "CMAKE_CXX_FLAGS", + "CMAKE_EXE_LINKER_FLAGS", + "CMAKE_MODULE_LINKER_FLAGS", + "CMAKE_SHARED_LINKER_FLAGS", + f"CMAKE_C_FLAGS_{build_type_suffix}", + f"CMAKE_CXX_FLAGS_{build_type_suffix}", + f"CMAKE_EXE_LINKER_FLAGS_{build_type_suffix}", + f"CMAKE_MODULE_LINKER_FLAGS_{build_type_suffix}", + f"CMAKE_SHARED_LINKER_FLAGS_{build_type_suffix}", + } + + config = {} + for name, value in raw_config.items(): + if not isinstance( name, str ) or re.fullmatch( r"[A-Z][A-Z0-9_]*", name ) is None: + raise ValueError( "measurement.build_config has an invalid CMake key" ) + relevant = ( + name in CMAKE_FIXED_CONFIG_NAMES + or name in active_flag_names + or name.startswith( "ENABLE_" ) + or name.startswith( "GEOS_BUILD_" ) + or name.startswith( "GEOS_ENABLE_" ) + or name.startswith( "LVARRAY_" ) + or name.startswith( "RAJA_ENABLE_" ) + ) + if not relevant: + raise ValueError( + f"measurement.build_config contains an irrelevant key: {name}" + ) + if isinstance( value, bool ): + config[name] = value + elif isinstance( value, str ): + if value != value.strip() or any( + character in value for character in "\0\r\n" + ): + raise ValueError( + f"measurement.build_config.{name} is not normalized" + ) + config[name] = value + else: + raise ValueError( + f"measurement.build_config.{name} must be a string or boolean" + ) + + missing = sorted( CMAKE_REQUIRED_CONFIG_NAMES - config.keys() ) + if missing: + raise ValueError( + "measurement.build_config is missing required keys: " + + ", ".join( missing ) + ) + if config["ENABLE_COVERAGE"] is not False: + raise ValueError( "measurement.build_config.ENABLE_COVERAGE must be false" ) + if config["GEOS_ENABLE_LLVM_SOURCE_COVERAGE"] is not True: + raise ValueError( + "measurement.build_config.GEOS_ENABLE_LLVM_SOURCE_COVERAGE must be true" + ) + if config["BUILD_SHARED_LIBS"] is not True or \ + config["GEOS_BUILD_SHARED_LIBS"] is not True: + raise ValueError( "measurement.build_config must select shared libraries" ) + return dict( sorted( config.items() ) ) + + +def contract_payload( scope: str, excluded_regex: str, measurement: dict ) -> dict: + container = measurement["container"] + return { + "summary_schema_version": COVERAGE_SUMMARY_SCHEMA_VERSION, + "contract_id": measurement["contract_id"], + "scope": scope, + "excluded_regex": excluded_regex, + "metric_semantics": measurement["metric_semantics"], + "container": { + "image_id": container["image_id"], + "image_digests": container["image_digests"], + }, + "toolchain": measurement["toolchain"], + "build_config": measurement["build_config"], + "object_selection": measurement["object_selection"], + } + + +def compute_contract_fingerprint( + scope: str, excluded_regex: str, measurement: dict +) -> str: + serialized = json.dumps( + contract_payload( scope, excluded_regex, measurement ), + allow_nan=False, + ensure_ascii=True, + separators=( ",", ":" ), + sort_keys=True, + ).encode( "utf-8" ) + return hashlib.sha256( serialized ).hexdigest() + + +def validate_measurement( + raw_measurement: object, scope: str, excluded_regex: str, tool_major: int +) -> dict: + if not isinstance( raw_measurement, dict ): + raise ValueError( "coverage summary measurement must be an object" ) + require_exact_keys( raw_measurement, MEASUREMENT_KEYS, "measurement" ) + + commit_sha = require_string( + raw_measurement.get( "commit_sha" ), "measurement.commit_sha" + ) + tree_sha = require_string( + raw_measurement.get( "tree_sha" ), "measurement.tree_sha" + ) + git_object_id = r"(?:[0-9a-f]{40}|[0-9a-f]{64})" + if re.fullmatch( git_object_id, commit_sha ) is None: + raise ValueError( "measurement.commit_sha must be a canonical Git object ID" ) + if re.fullmatch( git_object_id, tree_sha ) is None: + raise ValueError( "measurement.tree_sha must be a canonical Git object ID" ) + + contract_id = require_string( + raw_measurement.get( "contract_id" ), "measurement.contract_id" + ) + if contract_id != COVERAGE_CONTRACT_ID: + raise ValueError( + f"measurement.contract_id must be {COVERAGE_CONTRACT_ID!r}" + ) + fingerprint = require_string( + raw_measurement.get( "contract_fingerprint" ), + "measurement.contract_fingerprint", + ) + if re.fullmatch( r"[0-9a-f]{64}", fingerprint ) is None: + raise ValueError( + "measurement.contract_fingerprint must be a lowercase SHA256 digest" + ) + + container = validate_container( raw_measurement.get( "container" ) ) + toolchain = validate_toolchain( + raw_measurement.get( "toolchain" ), tool_major + ) + build_config = validate_build_config( raw_measurement.get( "build_config" ) ) + if raw_measurement.get( "metric_semantics" ) != METRIC_SEMANTICS: + raise ValueError( "measurement.metric_semantics is not the v1 contract" ) + if raw_measurement.get( "object_selection" ) != OBJECT_SELECTION: + raise ValueError( "measurement.object_selection is not the v1 contract" ) + + measurement = { + "commit_sha": commit_sha, + "tree_sha": tree_sha, + "contract_id": contract_id, + "contract_fingerprint": fingerprint, + "container": container, + "toolchain": toolchain, + "build_config": build_config, + "metric_semantics": dict( METRIC_SEMANTICS ), + "object_selection": dict( OBJECT_SELECTION ), + } + expected_fingerprint = compute_contract_fingerprint( + scope, excluded_regex, measurement + ) + if fingerprint != expected_fingerprint: + raise ValueError( + "measurement.contract_fingerprint does not match the coverage contract" + ) + return measurement + + +def validate_per_file_metrics( + raw_files: object, scope: str, canonical: dict +) -> list[dict]: + if not isinstance( raw_files, list ) or not raw_files: + raise ValueError( "per_file_metrics must be a nonempty array" ) + files = [] + seen_paths = set() + aggregates = { + name: { "covered": 0, "total": 0, "not_covered": 0 } + for name in CANONICAL_METRICS + } + for index, raw_file in enumerate( raw_files ): + if not isinstance( raw_file, dict ): + raise ValueError( f"per_file_metrics[{index}] must be an object" ) + require_exact_keys( + raw_file, { "path", "metrics" }, f"per_file_metrics[{index}]" + ) + path = validate_scoped_path( + raw_file.get( "path" ), scope, f"per_file_metrics[{index}].path" + ) + if path in seen_paths: + raise ValueError( "per_file_metrics paths must be unique" ) + seen_paths.add( path ) + raw_metrics = raw_file.get( "metrics" ) + if not isinstance( raw_metrics, dict ): + raise ValueError( f"per_file_metrics[{index}].metrics must be an object" ) + require_exact_keys( + raw_metrics, + set( CANONICAL_METRICS ), + f"per_file_metrics[{index}].metrics", + ) + metrics = { + name: validate_metric( + raw_metrics[name], + f"per_file_metrics[{index}].metrics.{name}", + minimum_total=0, + ) + for name in CANONICAL_METRICS + } + for name, metric in metrics.items(): + for count_name in ( "covered", "total", "not_covered" ): + aggregates[name][count_name] += metric[count_name] + files.append( { "path": path, "metrics": metrics } ) + + if files != sorted( files, key=lambda source: source["path"] ): + raise ValueError( "per_file_metrics must be sorted by path" ) + for name in CANONICAL_METRICS: + for count_name in ( "covered", "total", "not_covered" ): + if aggregates[name][count_name] != canonical[name][count_name]: + raise ValueError( + f"per_file_metrics {name} {count_name} does not match canonical totals" + ) + return files + + +def validate_branch_gaps( raw_gaps: object, scope: str ) -> list[dict]: + if not isinstance( raw_gaps, list ) or len( raw_gaps ) > 5: + raise ValueError( "top_branch_gaps must be an array with at most 5 entries" ) + + gaps = [] + seen_paths = set() + for index, raw_gap in enumerate( raw_gaps ): + if not isinstance( raw_gap, dict ): + raise ValueError( f"top_branch_gaps[{index}] must be an object" ) + path = validate_scoped_path( + raw_gap.get( "path" ), scope, f"top_branch_gaps[{index}].path" + ) + if path in seen_paths: + raise ValueError( "top_branch_gaps paths must be normalized and unique" ) + seen_paths.add( path ) + + gap = validate_metric( + raw_gap, + f"top_branch_gaps[{index}]", + allowed_extra_fields={ "path" }, + ) + if gap["not_covered"] == 0: + raise ValueError( "top_branch_gaps entries must have missed branches" ) + gaps.append( { "path": path, **gap } ) + + expected_order = sorted( + gaps, key=lambda gap: ( -gap["not_covered"], gap["path"] ) + ) + if gaps != expected_order: + raise ValueError( "top_branch_gaps must be sorted by missed branches" ) + return gaps + + +def validate_summary( document: dict ) -> dict: + require_schema_version( + document, "coverage summary", COVERAGE_SUMMARY_SCHEMA_VERSION + ) + scope = validate_repo_path( document.get( "scope" ), "coverage summary scope" ) + + require_exact_keys( + document, + { + "schema_version", + "scope", + "excluded_regex", + "measurement", + "tool", + "inputs", + "metrics", + "per_file_metrics", + "top_branch_gaps", + "supplemental", + }, + "coverage summary", + ) + excluded_regex = require_string( + document.get( "excluded_regex" ), "coverage summary excluded_regex" + ) + + tool = document.get( "tool" ) + if not isinstance( tool, dict ) or tool.get( "name" ) != "llvm-cov": + raise ValueError( "coverage summary tool.name must be llvm-cov" ) + require_exact_keys( tool, { "name", "major" }, "tool" ) + tool_major = require_integer( tool.get( "major" ), "tool.major", 1 ) + + measurement = validate_measurement( + document.get( "measurement" ), scope, excluded_regex, tool_major + ) + + raw_inputs = document.get( "inputs" ) + if not isinstance( raw_inputs, dict ): + raise ValueError( "coverage summary inputs must be an object" ) + require_exact_keys( + raw_inputs, + { "profiles", "coverage_objects", "zero_hash_mappings" }, + "inputs", + ) + inputs = { + "profiles": require_integer( raw_inputs.get( "profiles" ), "inputs.profiles", 1 ), + "coverage_objects": require_integer( + raw_inputs.get( "coverage_objects" ), "inputs.coverage_objects", 1 + ), + "zero_hash_mappings": require_integer( + raw_inputs.get( "zero_hash_mappings" ), "inputs.zero_hash_mappings" + ), + } + + raw_metrics = document.get( "metrics" ) + if not isinstance( raw_metrics, dict ): + raise ValueError( "coverage summary metrics must be an object" ) + require_exact_keys( raw_metrics, set( CANONICAL_METRICS ), "metrics" ) + metrics = { + name: validate_metric( raw_metrics.get( name ), f"metrics.{name}" ) + for name in CANONICAL_METRICS + } + + per_file_metrics = validate_per_file_metrics( + document.get( "per_file_metrics" ), scope, metrics + ) + + raw_supplemental = document.get( "supplemental" ) + if not isinstance( raw_supplemental, dict ): + raise ValueError( "coverage summary supplemental must be an object" ) + require_exact_keys( + raw_supplemental, + { name for name, _ in SUPPLEMENTAL_METRICS }, + "supplemental", + ) + supplemental = { + name: validate_metric( raw_supplemental.get( name ), f"supplemental.{name}" ) + for name, _ in SUPPLEMENTAL_METRICS + } + branch_gaps = validate_branch_gaps( document.get( "top_branch_gaps" ), scope ) + if sum( gap["total"] for gap in branch_gaps ) > metrics["branches"]["total"]: + raise ValueError( "top_branch_gaps totals exceed canonical branch coverage" ) + expected_branch_gaps = [] + for source in per_file_metrics: + branches = source["metrics"]["branches"] + if branches["not_covered"]: + expected_branch_gaps.append( { "path": source["path"], **branches } ) + expected_branch_gaps.sort( + key=lambda gap: ( -gap["not_covered"], gap["path"] ) + ) + if branch_gaps != expected_branch_gaps[:5]: + raise ValueError( + "top_branch_gaps does not match canonical per-file branch metrics" + ) + return { + "scope": scope, + "excluded_regex": excluded_regex, + "measurement": measurement, + "tool": { "name": "llvm-cov", "major": tool_major }, + "inputs": inputs, + "metrics": metrics, + "per_file_metrics": per_file_metrics, + "top_branch_gaps": branch_gaps, + "supplemental": supplemental, + } + + +def validate_policy( document: dict, expected_scope: str ) -> dict: + require_schema_version( + document, "coverage policy", COVERAGE_POLICY_SCHEMA_VERSION + ) + if document.get( "scope" ) != expected_scope: + raise ValueError( + f"coverage policy scope {document.get('scope')!r} does not match " + f"summary scope {expected_scope!r}" + ) + + raw_thresholds = document.get( "minimum_basis_points" ) + if not isinstance( raw_thresholds, dict ) or not raw_thresholds: + raise ValueError( "coverage policy minimum_basis_points must be nonempty" ) + + thresholds = {} + for name, value in raw_thresholds.items(): + if name not in CANONICAL_METRICS: + raise ValueError( f"unsupported coverage metric in policy: {name}" ) + thresholds[name] = require_integer( + value, f"minimum_basis_points.{name}" + ) + if thresholds[name] > 10000: + raise ValueError( f"minimum_basis_points.{name} cannot exceed 10000" ) + return thresholds + + +def percent_text( covered: int, total: int ) -> str: + return f"{100.0 * covered / total:.2f}%" + + +def build_markdown( summary: dict, thresholds: dict ) -> tuple[str, bool]: + threshold_results = { + name: summary["metrics"][name]["covered"] * 10000 + >= threshold * summary["metrics"][name]["total"] + for name, threshold in thresholds.items() + } + all_passed = all( threshold_results.values() ) + display_names = { + "regions": "Regions", + "functions": "Functions", + "lines": "Lines", + "branches": "Canonical branches", + } + metric_rows = [] + for name in DISPLAY_METRICS: + metric = summary["metrics"][name] + threshold = thresholds.get( name ) + if threshold is None: + requirement = "—" + result = "ℹ️ Measured" + else: + passed = threshold_results[name] + requirement = f"≥ {threshold / 100:.2f}%" + margin = 100.0 * metric["covered"] / metric["total"] - threshold / 100 + if 0 < abs( margin ) < 0.01: + direction = "above" if margin > 0 else "below" + margin_display = f"<0.01 pp {direction}" + else: + margin_display = f"{margin:+.2f} pp" + result = f"{'✅ Pass' if passed else '❌ Fail'} ({margin_display})" + metric_rows.append( + f"| {display_names[name]} | {metric['covered']:,} | " + f"{metric['not_covered']:,} | {metric['total']:,} | " + f"{percent_text(metric['covered'], metric['total'])} | " + f"{requirement} | {result} |" + ) + + policy_result = "passed" if all_passed else "failed" + policy_icon = "✅" if all_passed else "❌" + rows = [ + f"### {policy_icon} Coverage policy {policy_result}", + "", + f"Production scope: `{summary['scope']}` · " + f"Reporter: `{summary['tool']['name']} {summary['tool']['major']}`", + "", + "| Metric | Covered | Missed | Total | Coverage | Requirement | Result |", + "|---|---:|---:|---:|---:|---:|:---:|", + *metric_rows, + "", + "Percentages are rounded for display; policy thresholds use exact covered/total counts.", + ] + branch_gaps = summary["top_branch_gaps"] + if branch_gaps: + rows.extend( + ( + "", + "### Largest branch gaps", + "", + "| Source file | Covered | Missed | Total | Coverage |", + "|---|---:|---:|---:|---:|", + ) + ) + for gap in branch_gaps: + scope_prefix = f"{summary['scope'].rstrip( '/' )}/" + display_path = html.escape( gap["path"][len( scope_prefix ):] ) + display_path = display_path.replace( "|", "|" ) + display_path = display_path.replace( "\n", " " ).replace( + "\r", " " + ) + rows.append( + f"| {display_path} | {gap['covered']:,} | " + f"{gap['not_covered']:,} | {gap['total']:,} | " + f"{percent_text(gap['covered'], gap['total'])} |" + ) + rows.extend( + ( + "", + "Ranked by missed canonical branch outcomes within the production scope.", + ) + ) + + rows.extend( + ( + "", + "### Coverage collection", + "", + "| Input | Value |", + "|---|---:|", + f"| Profile files merged | {summary['inputs']['profiles']:,} |", + f"| Instrumented product objects | {summary['inputs']['coverage_objects']:,} |", + f"| Audited zero-hash mappings | {summary['inputs']['zero_hash_mappings']:,} |", + "", + "Zero-hash mappings have no profile counters and are audited during report " + "generation; any nonzero or unaudited mismatch fails the job.", + "", + "
", + "Branch diagnostic (not gated)", + "", + "| Diagnostic | Covered | Total | Coverage |", + "|---|---:|---:|---:|", + ) + ) + for name, display_name in SUPPLEMENTAL_METRICS: + metric = summary["supplemental"][name] + rows.append( + f"| {display_name} | {metric['covered']:,} | " + f"{metric['total']:,} | {percent_text(metric['covered'], metric['total'])} |" + ) + + rows.extend( + ( + "", + "Native outcomes are instantiation-weighted: C++ template and inline-function " + "instances may appear more than once. This diagnostic is not the policy metric.", + "", + "
", + ) + ) + return "\n".join( rows ) + "\n", all_passed + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( "summary", type=pathlib.Path ) + parser.add_argument( "policy", type=pathlib.Path ) + parser.add_argument( "--markdown", type=pathlib.Path ) + args = parser.parse_args() + + if args.markdown is not None: + try: + args.markdown.unlink( missing_ok=True ) + except OSError as error: + print( + f"coverage threshold error: cannot clear {args.markdown}: {error}", + file=sys.stderr, + ) + return 2 + + try: + summary = validate_summary( load_json( args.summary ) ) + thresholds = validate_policy( load_json( args.policy ), summary["scope"] ) + markdown, passed = build_markdown( summary, thresholds ) + except ValueError as error: + print( f"coverage threshold error: {error}", file=sys.stderr ) + return 2 + + if args.markdown is not None: + args.markdown.parent.mkdir( parents=True, exist_ok=True ) + args.markdown.write_text( markdown, encoding="utf-8" ) + else: + print( markdown, end="" ) + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit( main() ) diff --git a/scripts/ci_build_and_test_in_container.sh b/scripts/ci_build_and_test_in_container.sh index d1136071eee..d58562ae58b 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -154,8 +154,14 @@ Usage: $0 One of Debug, Release, RelWithDebInfo and MinSizeRel. Forwarded to CMAKE_BUILD_TYPE. --cmake-cuda-architectures ... Optional override for CMAKE_CUDA_ARCHITECTURES. - --code-coverage - run a code build and test. + --coverage-output-dir /path/to/output + Directory where LLVM source-coverage inputs and the HTML report are written. + --coverage-base-sha SHA + Exact pull-request base commit used for advisory patch coverage. + --coverage-head-sha SHA + Exact commit being built and measured by the coverage job. + --coverage-baseline-summary /path/to/coverage-summary.json + Optional trusted summary for the exact base commit. --ctest-parallel-level N Number of tests ctest may run in parallel. --data-basename output.tar.gz @@ -177,6 +183,9 @@ Usage: $0 The host-config. Path is relative to the root of the repository. --install-dir-basename GEOS-e42ffc1 GEOS installation basename. + --llvm-source-coverage + Build with Clang source coverage, run the coverage smoke suite, and + enforce the repository coverage policy. --makefile Use "Unix Makefiles" as build system generator. --ninja @@ -208,7 +217,7 @@ exit 1 # Then we'll move to the build dir. or_die cd $(dirname $0)/.. -args=$(or_die getopt -a -o h --long build-exe-only,cmake-build-type:,cmake-cuda-architectures:,code-coverage,ctest-parallel-level:,data-basename:,geos-enable-bounds-check:,enable-hypre:,enable-hypredrv:,enable-hypre-device:,enable-trilinos:,exchange-dir:,host-config:,install-dir-basename:,makefile,ninja,no-install-schema,no-run-unit-tests,nproc:,repository:,run-integrated-tests,sccache-credentials:,test-code-style,test-documentation,use-native-architecture,use-sccache,help -- "$@") +args=$(or_die getopt -a -o h --long build-exe-only,cmake-build-type:,cmake-cuda-architectures:,coverage-base-sha:,coverage-baseline-summary:,coverage-head-sha:,coverage-output-dir:,ctest-parallel-level:,data-basename:,geos-enable-bounds-check:,enable-hypre:,enable-hypredrv:,enable-hypre-device:,enable-trilinos:,exchange-dir:,host-config:,install-dir-basename:,llvm-source-coverage,makefile,ninja,no-install-schema,no-run-unit-tests,nproc:,repository:,run-integrated-tests,sccache-credentials:,test-code-style,test-documentation,use-native-architecture,use-sccache,help -- "$@") # Variables with default values BUILD_EXE_ONLY=false @@ -225,7 +234,11 @@ UPLOAD_TEST_BASELINES=false TEST_CODE_STYLE=false TEST_DOCUMENTATION=false ENABLE_TRILINOS=OFF -CODE_COVERAGE=false +LLVM_SOURCE_COVERAGE=false +COVERAGE_OUTPUT_DIR="" +COVERAGE_BASE_SHA="" +COVERAGE_HEAD_SHA="" +COVERAGE_BASELINE_SUMMARY="" CTEST_PARALLEL_LEVEL_ARG="" NPROC="$(nproc)" GEOS_ENABLE_BOUNDS_CHECK=ON @@ -235,7 +248,6 @@ USE_SCCACHE=false CMAKE_CUDA_ARCHITECTURES_ARGS=() CMAKE_NATIVE_ARCHITECTURE_ARGS=() ATS_CMAKE_ARGS=() -LCOV_CMAKE_ARGS="" eval set -- ${args} while : @@ -249,6 +261,10 @@ do --cmake-cuda-architectures) CMAKE_CUDA_ARCHITECTURES_ARGS+=("-DCMAKE_CUDA_ARCHITECTURES=$2") shift 2;; + --coverage-base-sha) COVERAGE_BASE_SHA=$2; shift 2;; + --coverage-baseline-summary) COVERAGE_BASELINE_SUMMARY=$2; shift 2;; + --coverage-head-sha) COVERAGE_HEAD_SHA=$2; shift 2;; + --coverage-output-dir) COVERAGE_OUTPUT_DIR=$2; shift 2;; --ninja) BUILD_GENERATOR=$1; shift;; @@ -270,6 +286,7 @@ do --exchange-dir) DATA_EXCHANGE_DIR=$2; shift 2;; --host-config) HOST_CONFIG=$2; shift 2;; --install-dir-basename) GEOS_DIR=${GEOSX_TPL_DIR}/../$2; shift 2;; + --llvm-source-coverage) LLVM_SOURCE_COVERAGE=true; shift;; --makefile) BUILD_GENERATOR=""; shift;; --no-install-schema) GEOS_INSTALL_SCHEMA=false; shift;; --no-run-unit-tests) RUN_UNIT_TESTS=false; shift;; @@ -281,7 +298,6 @@ do --repository) GEOS_SRC_DIR=$2; shift 2;; --run-integrated-tests) RUN_INTEGRATED_TESTS=true; shift;; --upload-test-baselines) UPLOAD_TEST_BASELINES=true; shift;; - --code-coverage) CODE_COVERAGE=true; shift;; --ctest-parallel-level) CTEST_PARALLEL_LEVEL_ARG=$2 shift 2;; @@ -419,39 +435,69 @@ if [[ "${RUN_INTEGRATED_TESTS}" = true ]]; then fi -if [[ "${CODE_COVERAGE}" = true ]]; then - or_die apt-get update - or_die apt-get install -y lcov +if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then + if [[ -z "${COVERAGE_OUTPUT_DIR}" ]]; then + echo "--llvm-source-coverage requires --coverage-output-dir." >&2 + exit 1 + fi + if [[ ! "${COVERAGE_HEAD_SHA}" =~ ^[0-9a-f]{40}$ || + "$(git -c "safe.directory=${GEOS_SRC_DIR}" -C "${GEOS_SRC_DIR}" rev-parse HEAD)" != "${COVERAGE_HEAD_SHA}" ]]; then + echo "--coverage-head-sha must identify the checked-out commit." >&2 + exit 1 + fi + if [[ -n "${COVERAGE_BASE_SHA}" ]]; then + if [[ ! "${COVERAGE_BASE_SHA}" =~ ^[0-9a-f]{40}$ ]] || + ! git -c "safe.directory=${GEOS_SRC_DIR}" -C "${GEOS_SRC_DIR}" \ + cat-file -e "${COVERAGE_BASE_SHA}^{commit}" 2>/dev/null; then + echo "--coverage-base-sha must identify an available commit." >&2 + exit 1 + fi + fi + if [[ -n "${COVERAGE_BASELINE_SUMMARY}" ]]; then + if [[ -z "${COVERAGE_BASE_SHA}" || + ! -f "${COVERAGE_BASELINE_SUMMARY}" || + -L "${COVERAGE_BASELINE_SUMMARY}" ]]; then + echo "--coverage-baseline-summary requires a safe file and a base SHA." >&2 + exit 1 + fi + fi - LCOV_REAL=$(command -v lcov || true) - if [[ -n "${LCOV_REAL}" ]]; then - export GEOS_REAL_LCOV="${LCOV_REAL}" - LCOV_WRAPPER=/tmp/geos-lcov-wrapper - cat > "${LCOV_WRAPPER}" <<'EOF' -#!/bin/bash -set -e - -extra_args=() -if "${GEOS_REAL_LCOV}" --version 2>&1 | grep -Eq 'LCOV version ([2-9]|[1-9][0-9])\.'; then - for arg in "$@"; do - case "${arg}" in - --capture|-c) - extra_args=(--ignore-errors mismatch,empty) - break - ;; - --remove|-r) - extra_args=(--ignore-errors unused) - break - ;; - esac + # The pinned Clang CI image intentionally contains the compiler and TPLs but + # not the compiler-native coverage reporter or profile runtime. Install the + # matching LLVM-major packages in this ephemeral container. + or_die apt-get update -qq + or_die env DEBIAN_FRONTEND=noninteractive apt-get install -y \ + --no-install-recommends llvm-20 libclang-rt-20-dev + export GEOS_CI_LLVM_PACKAGE_VERSIONS="$({ + dpkg-query -W -f='${Package}=${Version}\n' llvm-20 libclang-rt-20-dev + } | LC_ALL=C sort)" + or_die test -n "${GEOS_CI_LLVM_PACKAGE_VERSIONS}" + + for llvm_tool in llvm-cov-20 llvm-profdata-20 llvm-readelf-20; do + or_die command -v "${llvm_tool}" done -fi - -exec "${GEOS_REAL_LCOV}" "${extra_args[@]}" "$@" -EOF - or_die chmod +x "${LCOV_WRAPPER}" - LCOV_CMAKE_ARGS="-DLCOV_EXECUTABLE=${LCOV_WRAPPER}" - fi + or_die mkdir -p "${COVERAGE_OUTPUT_DIR}" + for coverage_artifact in \ + CMakeCache.txt \ + branch-summary.txt \ + coverage-objects.txt \ + coverage-status.md \ + coverage-summary.json \ + coverage-summary.md \ + ctest.log \ + index.html \ + llvm-summary.json \ + mapping-integrity.log \ + native-export.log \ + pr-coverage.json \ + pr-coverage.md \ + summary-export.log \ + toolchain.txt; do + or_die rm -f -- "${COVERAGE_OUTPUT_DIR}/${coverage_artifact}" + done + or_die python3 -m unittest discover \ + -s "${GEOS_SRC_DIR}/scripts/tests" \ + -p 'test_*coverage*.py' fi # The -DBLT_MPI_COMMAND_APPEND="--allow-run-as-root;--oversubscribe" option is added for OpenMPI. @@ -468,6 +514,20 @@ fi # This will tells OpenMPI to discover the number of hardware threads on the node, # and use that as the number of slots available. (There is a distinction between threads and cores). GEOS_BUILD_DIR=/tmp/geos-build +LLVM_COVERAGE_CMAKE_ARGS=( + -DENABLE_COVERAGE=OFF + -DGEOS_ENABLE_LLVM_SOURCE_COVERAGE=$([[ "${LLVM_SOURCE_COVERAGE}" = true ]] && echo ON || echo OFF) +) +if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then + LLVM_COVERAGE_CMAKE_ARGS+=( + -DGEOS_BUILD_SHARED_LIBS=ON + -DENABLE_DOXYGEN=OFF + ) + LLVM_BUILD_PROFILE_DIR=/tmp/geos-coverage-build-profiles + or_die mkdir -p "${LLVM_BUILD_PROFILE_DIR}" + export LLVM_PROFILE_FILE="${LLVM_BUILD_PROFILE_DIR}/%p-%m.profraw" +fi + or_die python3 scripts/config-build.py \ -hc ${HOST_CONFIG} \ -bt ${CMAKE_BUILD_TYPE} \ @@ -481,12 +541,11 @@ or_die python3 scripts/config-build.py \ -DENABLE_HYPRE_DEVICE=${ENABLE_HYPRE_DEVICE} \ -DENABLE_TRILINOS=${ENABLE_TRILINOS} \ -DGEOS_LA_INTERFACE:PATH=${GEOS_LA_INTERFACE} \ - -DENABLE_COVERAGE=$([[ "${CODE_COVERAGE}" = true ]] && echo 1 || echo 0) \ -DGEOS_ENABLE_BOUNDS_CHECK=${GEOS_ENABLE_BOUNDS_CHECK} \ "${CMAKE_CUDA_ARCHITECTURES_ARGS[@]}" \ "${CMAKE_NATIVE_ARCHITECTURE_ARGS[@]}" \ ${SCCACHE_CMAKE_ARGS} \ - ${LCOV_CMAKE_ARGS} \ + "${LLVM_COVERAGE_CMAKE_ARGS[@]}" \ "${ATS_CMAKE_ARGS[@]}" # The configuration step is now over, we can now move to the build directory for the build! @@ -506,9 +565,9 @@ fi # Performing the requested build. if [[ "${BUILD_EXE_ONLY}" = true ]]; then - or_die cmake --build . -j $NPROC --target geosx + or_die cmake --build . --parallel "${NPROC}" --target geosx else - or_die cmake --build . -j $NPROC + or_die cmake --build . --parallel "${NPROC}" #or_die cmake --install . if [[ ! -z "${DATA_BASENAME_WE}" ]]; then @@ -536,10 +595,274 @@ if [[ -n "${SCCACHE_BIN}" ]]; then fi fi -if [[ "${CODE_COVERAGE}" = true ]]; then +if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then + LLVM_TEST_PROFILE_DIR=/tmp/geos-coverage-profiles + LLVM_REPORT_DIR=/tmp/geos-coverage-report + LLVM_CTEST_PARALLEL="${CTEST_PARALLEL_LEVEL_ARG:-12}" + or_die mkdir -p "${LLVM_TEST_PROFILE_DIR}" "${LLVM_REPORT_DIR}" + export OMP_NUM_THREADS=1 - or_die cmake --build . --target coreComponents_coverage - or_die cp -r ${GEOS_BUILD_DIR}/coreComponents_coverage.info.cleaned ${GEOS_SRC_DIR}/geos_coverage.info.cleaned + export OPENBLAS_NUM_THREADS=1 + export LLVM_PROFILE_FILE="${LLVM_TEST_PROFILE_DIR}/%p-%m.profraw" + + # The CI container runs as root so it can install the compiler-matched LLVM + # runtime. Open MPI requires this explicit, two-part acknowledgement before + # it will launch the three MPI coverage smokes. + if [[ "$(id -u)" -eq 0 ]]; then + export OMPI_ALLOW_RUN_AS_ROOT=1 + export OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1 + fi + + { + printf 'container image: %s\n' "${GEOS_CI_CONTAINER_IMAGE:-unknown}" + printf 'container image ID: %s\n' "${GEOS_CI_CONTAINER_IMAGE_ID:-unknown}" + printf 'container image digests: %s\n' "${GEOS_CI_CONTAINER_IMAGE_DIGESTS:-unknown}" + or_die /usr/bin/clang-20 --version + or_die llvm-cov-20 --version + or_die llvm-profdata-20 --version + or_die llvm-readelf-20 --version + or_die cmake --version + or_die dpkg-query -W -f='${Package}=${Version}\n' llvm-20 libclang-rt-20-dev + } > "${COVERAGE_OUTPUT_DIR}/toolchain.txt" + or_die test -s "${COVERAGE_OUTPUT_DIR}/toolchain.txt" + + coverage_ctest_status=0 + ctest --test-dir "${GEOS_BUILD_DIR}" \ + --parallel "${LLVM_CTEST_PARALLEL}" \ + --progress \ + --output-on-failure \ + --output-log "${COVERAGE_OUTPUT_DIR}/ctest.log" \ + -L '^coverage_smoke$' || coverage_ctest_status=$? + + coverage_report_status=0 + "${GEOS_SRC_DIR}/scripts/llvm_source_branch_coverage.sh" \ + "${GEOS_BUILD_DIR}" \ + "${LLVM_TEST_PROFILE_DIR}" \ + "${LLVM_REPORT_DIR}" || coverage_report_status=$? + + coverage_pr_status=0 + coverage_pr_ran=false + if [[ ${coverage_report_status} -eq 0 && -n "${COVERAGE_BASE_SHA}" ]]; then + coverage_pr_ran=true + coverage_pr_args=( + --repository "${GEOS_SRC_DIR}" + --base-sha "${COVERAGE_BASE_SHA}" + --head-sha "${COVERAGE_HEAD_SHA}" + --native-info "${LLVM_REPORT_DIR}/native.info" + --candidate-summary "${LLVM_REPORT_DIR}/coverage-summary.json" + --output-json "${COVERAGE_OUTPUT_DIR}/pr-coverage.json" + --output-markdown "${COVERAGE_OUTPUT_DIR}/pr-coverage.md" + ) + if [[ -n "${COVERAGE_BASELINE_SUMMARY}" ]]; then + coverage_pr_args+=(--baseline-summary "${COVERAGE_BASELINE_SUMMARY}") + fi + python3 "${GEOS_SRC_DIR}/scripts/compare_pr_coverage.py" \ + "${coverage_pr_args[@]}" || coverage_pr_status=$? + if [[ ${coverage_pr_status} -eq 0 ]]; then + echo "LLVM pull-request coverage report written to ${COVERAGE_OUTPUT_DIR}/pr-coverage.md" + fi + fi + + coverage_gate_status=0 + coverage_gate_ran=false + if [[ ${coverage_report_status} -eq 0 ]]; then + coverage_gate_ran=true + python3 "${GEOS_SRC_DIR}/scripts/check_coverage_thresholds.py" \ + "${LLVM_REPORT_DIR}/coverage-summary.json" \ + "${GEOS_SRC_DIR}/.github/coverage-thresholds.json" \ + --markdown "${COVERAGE_OUTPUT_DIR}/coverage-summary.md" \ + || coverage_gate_status=$? + if [[ ${coverage_gate_status} -eq 0 ]]; then + echo "LLVM coverage policy report written to ${COVERAGE_OUTPUT_DIR}/coverage-summary.md" + fi + python3 "${GEOS_SRC_DIR}/scripts/render_coverage_review_summary.py" \ + --coverage-summary "${LLVM_REPORT_DIR}/coverage-summary.json" \ + --thresholds "${GEOS_SRC_DIR}/.github/coverage-thresholds.json" \ + --console || echo "LLVM coverage console summary unavailable." >&2 + fi + + for report_file in \ + branch-summary.txt \ + coverage-summary.json \ + coverage-objects.txt \ + llvm-summary.json \ + mapping-integrity.log \ + native-export.log \ + summary-export.log; do + if [[ -f "${LLVM_REPORT_DIR}/${report_file}" ]]; then + cp -- "${LLVM_REPORT_DIR}/${report_file}" "${COVERAGE_OUTPUT_DIR}/" \ + || coverage_report_status=1 + fi + done + cp -- "${GEOS_BUILD_DIR}/CMakeCache.txt" "${COVERAGE_OUTPUT_DIR}/" \ + || coverage_report_status=1 + + coverage_overall_status=PASS + if [[ ${coverage_ctest_status} -ne 0 || + ${coverage_report_status} -ne 0 || + ${coverage_gate_status} -ne 0 || + ${coverage_pr_status} -ne 0 ]]; then + coverage_overall_status=FAIL + fi + + coverage_phase_status() + { + if [[ "${2:-true}" != true ]]; then + printf '⏭️ Not run' + return + fi + if [[ "$1" -eq 0 ]]; then + printf '✅ Pass' + else + printf '❌ Fail (%d)' "$1" + fi + } + + coverage_ctest_detail="See the retained CTest log." + coverage_ctest_summary="$( + grep -E '^[0-9]+% tests passed, [0-9]+ tests failed out of [0-9]+$' \ + "${COVERAGE_OUTPUT_DIR}/ctest.log" | tail -n 1 || true + )" + coverage_ctest_elapsed_line="$( + grep -E '^Total Test time \(real\) = +[0-9.]+ sec$' \ + "${COVERAGE_OUTPUT_DIR}/ctest.log" | tail -n 1 || true + )" + coverage_ctest_summary_regex='^[0-9]+% tests passed, ([0-9]+) tests failed out of ([0-9]+)$' + coverage_ctest_elapsed_regex='^Total Test time \(real\) = +([0-9.]+) sec$' + if [[ "${coverage_ctest_summary}" =~ ${coverage_ctest_summary_regex} ]]; then + coverage_ctest_failed="${BASH_REMATCH[1]}" + coverage_ctest_total="${BASH_REMATCH[2]}" + coverage_ctest_passed=$(( coverage_ctest_total - coverage_ctest_failed )) + coverage_ctest_detail="${coverage_ctest_passed}/${coverage_ctest_total} passed" + if (( coverage_ctest_failed > 0 )); then + coverage_ctest_detail+=", ${coverage_ctest_failed} failed" + fi + if [[ "${coverage_ctest_elapsed_line}" =~ ${coverage_ctest_elapsed_regex} ]]; then + coverage_ctest_detail+=" in ${BASH_REMATCH[1]} s" + fi + coverage_ctest_detail+=" with ${LLVM_CTEST_PARALLEL}-way scheduling" + fi + + if [[ ${coverage_report_status} -eq 0 ]]; then + coverage_report_detail="Profile mappings and report inputs validated" + else + coverage_report_detail="Report generation or artifact staging failed; inspect the logs" + fi + if [[ "${coverage_gate_ran}" != true ]]; then + coverage_gate_detail="Skipped because the LLVM report was unavailable" + elif [[ ${coverage_gate_status} -eq 0 ]]; then + coverage_gate_detail="Exact repository thresholds evaluated below" + else + coverage_gate_detail="Repository threshold check failed" + fi + if [[ "${coverage_pr_ran}" != true ]]; then + if [[ -n "${COVERAGE_BASE_SHA}" ]]; then + coverage_pr_detail="Skipped because the LLVM report was unavailable" + else + coverage_pr_detail="Not a pull-request coverage run" + fi + elif [[ ${coverage_pr_status} -eq 0 ]]; then + coverage_pr_detail="Changed-code coverage and exact-base comparison rendered below" + else + coverage_pr_detail="Patch-specific coverage analysis failed" + fi + + coverage_status_markdown="${COVERAGE_OUTPUT_DIR}/coverage-status.md" + coverage_status_tmp="${coverage_status_markdown}.tmp" + coverage_image_id="${GEOS_CI_CONTAINER_IMAGE_ID:-unknown}" + coverage_image_id_display="${coverage_image_id}" + if (( ${#coverage_image_id_display} > 20 )); then + coverage_image_id_display="${coverage_image_id_display:0:20}…" + fi + coverage_clang_version="$( + /usr/bin/clang-20 --version | sed -nE \ + '1s/.*version[[:space:]]+([0-9]+([.][0-9]+)+).*/\1/p' + )" + if [[ -z "${coverage_clang_version}" ]]; then + coverage_clang_version=20 + fi + coverage_llvm_version="$( + llvm-cov-20 --version | sed -nE \ + '1s/.*version[[:space:]]+([0-9]+([.][0-9]+)+).*/\1/p' + )" + if [[ -z "${coverage_llvm_version}" ]]; then + coverage_llvm_version=20 + fi + coverage_reviewer_args=( + --coverage-summary "${LLVM_REPORT_DIR}/coverage-summary.json" + --thresholds "${GEOS_SRC_DIR}/.github/coverage-thresholds.json" + --tests-result "$(coverage_phase_status "${coverage_ctest_status}")" + --tests-detail "${coverage_ctest_detail}" + ) + if [[ "${coverage_pr_ran}" = true && + -f "${COVERAGE_OUTPUT_DIR}/pr-coverage.json" && + ! -L "${COVERAGE_OUTPUT_DIR}/pr-coverage.json" ]]; then + coverage_reviewer_args+=(--pr-report "${COVERAGE_OUTPUT_DIR}/pr-coverage.json") + fi + coverage_reviewer_summary_status=0 + coverage_reviewer_summary="$( + python3 "${GEOS_SRC_DIR}/scripts/render_coverage_review_summary.py" \ + "${coverage_reviewer_args[@]}" + )" || coverage_reviewer_summary_status=$? + if [[ ${coverage_reviewer_summary_status} -ne 0 ]]; then + coverage_reviewer_summary=$'### Reviewer summary\n\nCoverage reviewer summary unavailable; inspect the detailed reports below.' + fi + { + if [[ "${coverage_overall_status}" = PASS ]]; then + printf '### ✅ LLVM source coverage passed\n\n' + else + printf '### ❌ LLVM source coverage failed\n\n' + fi + printf '%s\n' "${coverage_reviewer_summary}" + printf '### Validation details\n\n' + printf '| Validation | Result | Details |\n' + printf '|---|:---:|---|\n' + printf '| Coverage smoke suite | %s | %s |\n' \ + "$(coverage_phase_status "${coverage_ctest_status}")" "${coverage_ctest_detail}" + printf '| LLVM report integrity | %s | %s |\n' \ + "$(coverage_phase_status "${coverage_report_status}")" "${coverage_report_detail}" + printf '| Repository coverage policy | %s | %s |\n' \ + "$(coverage_phase_status "${coverage_gate_status}" "${coverage_gate_ran}")" \ + "${coverage_gate_detail}" + printf '| PR coverage analysis | %s | %s |\n' \ + "$(coverage_phase_status "${coverage_pr_status}" "${coverage_pr_ran}")" \ + "${coverage_pr_detail}" + printf '\n' + printf '### Run configuration\n\n' + printf '| Item | Value |\n' + printf '|---|---|\n' + printf '| Runner | `%s` |\n' "${GEOS_CI_RUNNER_LABEL:-unknown}" + printf '| Container | `%s` |\n' "${GEOS_CI_CONTAINER_IMAGE:-unknown}" + printf '| Immutable image ID (short) | `%s` |\n' "${coverage_image_id_display}" + printf '| Toolchain | `Clang %s / LLVM %s` |\n' \ + "${coverage_clang_version}" "${coverage_llvm_version}" + printf '| Build | `%s`, %s-way compilation |\n' "${CMAKE_BUILD_TYPE}" "${NPROC}" + printf '| Test scheduling | %s CTest slots |\n' "${LLVM_CTEST_PARALLEL}" + printf '\n' + } > "${coverage_status_tmp}" + or_die mv -- "${coverage_status_tmp}" "${coverage_status_markdown}" + + coverage_html_status=0 + python3 "${GEOS_SRC_DIR}/scripts/render_coverage_report_html.py" \ + --artifact-dir "${COVERAGE_OUTPUT_DIR}" \ + --output "${COVERAGE_OUTPUT_DIR}/index.html" \ + || coverage_html_status=$? + if [[ ${coverage_html_status} -eq 0 ]]; then + echo "LLVM source coverage HTML report written to ${COVERAGE_OUTPUT_DIR}/index.html" + else + echo "LLVM source coverage HTML report generation failed." >&2 + fi + + if [[ "${coverage_overall_status}" = FAIL ]]; then + echo "LLVM source coverage failed: ctest=${coverage_ctest_status}, " \ + "report=${coverage_report_status}, gate=${coverage_gate_status}, " \ + "pr=${coverage_pr_status}, html=${coverage_html_status}." >&2 + exit 1 + fi + if [[ ${coverage_html_status} -ne 0 ]]; then + exit 1 + fi + exit 0 fi # Run the unit tests (excluding previously ran checks). diff --git a/scripts/compare_pr_coverage.py b/scripts/compare_pr_coverage.py new file mode 100755 index 00000000000..8f0a7e5e8a9 --- /dev/null +++ b/scripts/compare_pr_coverage.py @@ -0,0 +1,1215 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +"""Build a vendor-independent, advisory pull-request coverage report.""" + +from __future__ import annotations + +import argparse +import html +import json +import math +import os +import pathlib +import re +import subprocess +import sys +import tempfile +from typing import Iterable + + +SCRIPT_DIRECTORY = pathlib.Path( __file__ ).resolve().parent +if str( SCRIPT_DIRECTORY ) not in sys.path: + sys.path.insert( 0, str( SCRIPT_DIRECTORY ) ) +import check_coverage_thresholds as coverage_contract + + +REPORT_SCHEMA_VERSION = 1 +SUPPORTED_SUMMARY_SCHEMA_VERSIONS = ( 2, 3 ) +PRODUCTION_SCOPE = "src/coreComponents" +CANONICAL_METRICS = ( "regions", "functions", "lines", "branches" ) +DISPLAY_METRICS = ( "lines", "functions", "branches", "regions" ) +SHA_PATTERN = re.compile( r"^[0-9a-fA-F]{40}$" ) +HUNK_PATTERN = re.compile( + rb"^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@" +) +MAX_IMPACTED_FILES = 10 +MAX_IMPACTED_LOCATIONS = 10 +MAX_FUNCTION_NAMES = 3 + + +def require_integer( value, description: str, minimum: int = 0 ) -> int: + if isinstance( value, bool ) or not isinstance( value, int ) or value < minimum: + raise ValueError( f"{description} must be an integer >= {minimum}" ) + return value + + +def validate_sha( value: str, description: str ) -> str: + if not isinstance( value, str ) or SHA_PATTERN.fullmatch( value ) is None: + raise ValueError( f"{description} must be a full 40-character Git SHA" ) + return value.lower() + + +def read_json( path: pathlib.Path, description: str ) -> dict: + try: + with path.open( encoding="utf-8" ) as stream: + document = json.load( stream ) + except ( OSError, UnicodeError, json.JSONDecodeError ) as error: + raise ValueError( f"cannot read {description} {path}: {error}" ) from error + if not isinstance( document, dict ): + raise ValueError( f"{description} must contain a JSON object" ) + return document + + +def validate_metric( raw_metric: object, description: str ) -> dict: + if not isinstance( raw_metric, dict ): + raise ValueError( f"{description} must be an object" ) + covered = require_integer( raw_metric.get( "covered" ), f"{description}.covered" ) + total = require_integer( raw_metric.get( "total" ), f"{description}.total", 1 ) + not_covered = require_integer( + raw_metric.get( "not_covered" ), f"{description}.not_covered" + ) + if covered > total or not_covered != total - covered: + raise ValueError( f"{description} counts are inconsistent" ) + percent = raw_metric.get( "percent" ) + if isinstance( percent, bool ) or not isinstance( percent, ( int, float ) ): + raise ValueError( f"{description}.percent must be numeric" ) + expected_percent = round( 100.0 * covered / total, 6 ) + if not math.isfinite( float( percent ) ) or abs( + float( percent ) - expected_percent + ) > 1.0e-6: + raise ValueError( f"{description}.percent is inconsistent with its counts" ) + return { + "covered": covered, + "not_covered": not_covered, + "total": total, + "percent": expected_percent, + } + + +def validate_summary( document: dict, description: str ) -> dict: + schema_version = document.get( "schema_version" ) + if ( + isinstance( schema_version, bool ) + or not isinstance( schema_version, int ) + or schema_version not in SUPPORTED_SUMMARY_SCHEMA_VERSIONS + ): + supported = ", ".join( str( value ) for value in SUPPORTED_SUMMARY_SCHEMA_VERSIONS ) + raise ValueError( + f"{description}.schema_version must be one of the integers {supported}" + ) + if schema_version == coverage_contract.COVERAGE_SUMMARY_SCHEMA_VERSION: + try: + validated = coverage_contract.validate_summary( document ) + except ValueError as error: + raise ValueError( f"{description} is invalid: {error}" ) from error + return { + "schema_version": schema_version, + "scope": validated["scope"], + "excluded_regex": validated["excluded_regex"], + "tool": validated["tool"], + "metrics": validated["metrics"], + "measurement": validated["measurement"], + "per_file_metrics": validated["per_file_metrics"], + } + scope = document.get( "scope" ) + excluded_regex = document.get( "excluded_regex" ) + if not isinstance( scope, str ) or not scope: + raise ValueError( f"{description}.scope must be a nonempty string" ) + if not isinstance( excluded_regex, str ) or not excluded_regex: + raise ValueError( f"{description}.excluded_regex must be a nonempty string" ) + tool = document.get( "tool" ) + if not isinstance( tool, dict ) or tool.get( "name" ) != "llvm-cov": + raise ValueError( f"{description}.tool.name must be llvm-cov" ) + tool_major = require_integer( tool.get( "major" ), f"{description}.tool.major", 1 ) + raw_metrics = document.get( "metrics" ) + if not isinstance( raw_metrics, dict ): + raise ValueError( f"{description}.metrics must be an object" ) + metrics = { + name: validate_metric( raw_metrics.get( name ), f"{description}.metrics.{name}" ) + for name in CANONICAL_METRICS + } + + raw_measurement = document.get( "measurement" ) + measurement = None + if raw_measurement is not None: + raise ValueError( f"{description}.measurement is not valid in schema 2" ) + + return { + "schema_version": schema_version, + "scope": scope, + "excluded_regex": excluded_regex, + "tool": { "name": "llvm-cov", "major": tool_major }, + "metrics": metrics, + "measurement": measurement, + "per_file_metrics": None, + } + + +def checked_input_file( path: pathlib.Path, description: str ) -> pathlib.Path: + if path.is_symlink(): + raise ValueError( f"{description} must not be a symbolic link: {path}" ) + try: + resolved = path.resolve( strict=True ) + except OSError as error: + raise ValueError( f"cannot resolve {description} {path}: {error}" ) from error + if not resolved.is_file(): + raise ValueError( f"{description} is not a regular file: {path}" ) + return resolved + + +def run_git( repository: pathlib.Path, arguments: Iterable[str] ) -> bytes: + command = [ + "git", + "-c", + f"safe.directory={repository}", + "-C", + str( repository ), + *arguments, + ] + try: + result = subprocess.run( + command, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as error: + raise ValueError( f"cannot execute Git: {error}" ) from error + if result.returncode != 0: + message = result.stderr.decode( "utf-8", errors="replace" ).strip() + raise ValueError( f"Git command failed: {message or 'unknown error'}" ) + return result.stdout + + +def validate_repository( repository: pathlib.Path ) -> pathlib.Path: + try: + resolved = repository.resolve( strict=True ) + except OSError as error: + raise ValueError( f"cannot resolve repository {repository}: {error}" ) from error + if not resolved.is_dir(): + raise ValueError( f"repository is not a directory: {resolved}" ) + top_level = run_git( resolved, ( "rev-parse", "--show-toplevel" ) ) + try: + git_root = pathlib.Path( top_level.decode( "utf-8" ).strip() ).resolve( + strict=True + ) + except ( UnicodeError, OSError ) as error: + raise ValueError( f"cannot resolve Git repository root: {error}" ) from error + if git_root != resolved: + raise ValueError( f"--repository must name the Git top level: {git_root}" ) + return resolved + + +def validate_commit( repository: pathlib.Path, sha: str, description: str ) -> str: + normalized = validate_sha( sha, description ) + resolved = run_git( + repository, ( "rev-parse", "--verify", f"{normalized}^{{commit}}" ) + ).decode( "ascii", errors="strict" ).strip() + if resolved.lower() != normalized: + raise ValueError( f"{description} does not resolve to the requested commit" ) + return normalized + + +def commit_tree( repository: pathlib.Path, sha: str ) -> str: + tree = run_git( + repository, ( "rev-parse", "--verify", f"{sha}^{{tree}}" ) + ).decode( "ascii", errors="strict" ).strip() + return validate_sha( tree, f"tree for {sha}" ) + + +def decode_git_path( value: bytes ) -> str: + try: + path = value.decode( "utf-8", errors="strict" ) + except UnicodeError as error: + raise ValueError( "Git paths must be valid UTF-8" ) from error + normalized = pathlib.PurePosixPath( path ) + if ( + not path + or normalized.is_absolute() + or ".." in normalized.parts + or normalized.as_posix() != path + ): + raise ValueError( f"Git returned an unsafe or non-normalized path: {path!r}" ) + return path + + +def changed_line_ranges( + repository: pathlib.Path, base_sha: str, head_sha: str +) -> list[dict]: + raw_names = run_git( + repository, + ( + "diff", + "--name-status", + "-z", + "--find-renames", + "--find-copies-harder", + "--diff-filter=ACMR", + base_sha, + head_sha, + "--", + PRODUCTION_SCOPE, + ), + ) + fields = raw_names.split( b"\0" ) + if fields and fields[-1] == b"": + fields.pop() + + records = [] + index = 0 + scope_prefix = f"{PRODUCTION_SCOPE}/" + while index < len( fields ): + try: + status_text = fields[index].decode( "ascii", errors="strict" ) + except UnicodeError as error: + raise ValueError( "Git returned a non-ASCII change status" ) from error + index += 1 + if not status_text or status_text[0] not in "ACMR": + raise ValueError( f"Git returned an unexpected change status: {status_text!r}" ) + status = status_text[0] + if status in "RC": + if index + 1 >= len( fields ): + raise ValueError( "Git returned a truncated rename/copy record" ) + old_path = decode_git_path( fields[index] ) + path = decode_git_path( fields[index + 1] ) + index += 2 + diff_paths = ( old_path, path ) + else: + if index >= len( fields ): + raise ValueError( "Git returned a truncated change record" ) + path = decode_git_path( fields[index] ) + index += 1 + old_path = None + diff_paths = ( path, ) + + # A rename out of the production tree can be selected by its old path. + # It has no head lines in scope and therefore is not a patch denominator. + if not path.startswith( scope_prefix ): + continue + + patch = run_git( + repository, + ( + "diff", + "--unified=0", + "--no-color", + "--no-ext-diff", + "--find-renames", + "--find-copies-harder", + base_sha, + head_sha, + "--", + *diff_paths, + ), + ) + lines = set() + for patch_line in patch.splitlines(): + match = HUNK_PATTERN.match( patch_line ) + if match is None: + continue + start = int( match.group( 1 ) ) + count = int( match.group( 2 ) or b"1" ) + if count: + lines.update( range( start, start + count ) ) + records.append( + { + "status": status, + "path": path, + "old_path": old_path, + "lines": frozenset( lines ), + } + ) + + records.sort( key=lambda record: record["path"] ) + seen_paths = set() + for record in records: + if record["path"] in seen_paths: + raise ValueError( f"duplicate changed path from Git: {record['path']}" ) + seen_paths.add( record["path"] ) + return records + + +def normalize_lcov_source( source: str, repository: pathlib.Path ) -> str | None: + candidate = pathlib.Path( source ) + if not candidate.is_absolute(): + candidate = repository / candidate + try: + resolved = candidate.resolve( strict=False ) + relative = resolved.relative_to( repository ) + except ( OSError, ValueError ): + return None + path = relative.as_posix() + if not path.startswith( f"{PRODUCTION_SCOPE}/" ): + return None + return path + + +def parse_nonnegative_integer( value: str, description: str, minimum: int = 0 ) -> int: + try: + parsed = int( value, 10 ) + except ValueError as error: + raise ValueError( f"{description} must be an integer" ) from error + if parsed < minimum: + raise ValueError( f"{description} must be >= {minimum}" ) + return parsed + + +def parse_native_lcov( + native_info: pathlib.Path, + repository: pathlib.Path, + changed_files: list[dict], +) -> dict: + changed_lines = { + record["path"]: set( record["lines"] ) for record in changed_files + } + line_counts: dict[tuple[str, int], int] = {} + branch_outcomes: dict[tuple[str, int, int, int], bool] = {} + function_names: dict[tuple[str, int], set[str]] = {} + relevant_function_names: dict[tuple[str, str], set[int]] = {} + function_counts: dict[tuple[str, str], int] = {} + current_path = None + + try: + stream = native_info.open( encoding="utf-8", errors="strict" ) + except OSError as error: + raise ValueError( f"cannot read native LCOV input {native_info}: {error}" ) from error + try: + with stream: + for line_number, raw_line in enumerate( stream, 1 ): + line = raw_line.rstrip( "\r\n" ) + if line.startswith( "SF:" ): + current_path = normalize_lcov_source( line[3:], repository ) + if current_path not in changed_lines: + current_path = None + continue + if line == "end_of_record": + current_path = None + continue + if current_path is None: + continue + + if line.startswith( "DA:" ): + fields = line[3:].split( ",", 2 ) + if len( fields ) < 2: + raise ValueError( f"malformed DA record at LCOV line {line_number}" ) + source_line = parse_nonnegative_integer( + fields[0], f"DA source line at LCOV line {line_number}", 1 + ) + count = parse_nonnegative_integer( + fields[1], f"DA count at LCOV line {line_number}" + ) + if source_line in changed_lines[current_path]: + key = ( current_path, source_line ) + line_counts[key] = max( line_counts.get( key, 0 ), count ) + continue + + if line.startswith( "FN:" ): + fields = line[3:].split( ",", 1 ) + if len( fields ) != 2 or not fields[1]: + raise ValueError( f"malformed FN record at LCOV line {line_number}" ) + source_line = parse_nonnegative_integer( + fields[0], f"FN source line at LCOV line {line_number}", 1 + ) + if source_line in changed_lines[current_path]: + name = fields[1] + function_names.setdefault( + ( current_path, source_line ), set() + ).add( name ) + relevant_function_names.setdefault( + ( current_path, name ), set() + ).add( source_line ) + continue + + if line.startswith( "FNDA:" ): + fields = line[5:].split( ",", 1 ) + if len( fields ) != 2 or not fields[1]: + raise ValueError( f"malformed FNDA record at LCOV line {line_number}" ) + name = fields[1] + if ( current_path, name ) not in relevant_function_names: + continue + count = parse_nonnegative_integer( + fields[0], f"FNDA count at LCOV line {line_number}" + ) + key = ( current_path, name ) + function_counts[key] = max( function_counts.get( key, 0 ), count ) + continue + + if line.startswith( "BRDA:" ): + fields = line[5:].split( ",", 3 ) + if len( fields ) != 4: + raise ValueError( f"malformed BRDA record at LCOV line {line_number}" ) + source_line = parse_nonnegative_integer( + fields[0], f"BRDA source line at LCOV line {line_number}", 1 + ) + if source_line not in changed_lines[current_path]: + continue + pair_id = parse_nonnegative_integer( + fields[1], f"BRDA pair id at LCOV line {line_number}" + ) + branch_id = parse_nonnegative_integer( + fields[2], f"BRDA branch id at LCOV line {line_number}" + ) + if fields[3] == "-": + covered = False + else: + covered = parse_nonnegative_integer( + fields[3], f"BRDA count at LCOV line {line_number}" + ) > 0 + key = ( current_path, source_line, pair_id, branch_id ) + branch_outcomes[key] = branch_outcomes.get( key, False ) or covered + except UnicodeError as error: + raise ValueError( f"native LCOV input must be valid UTF-8: {error}" ) from error + + function_sites = {} + for site, names in function_names.items(): + covered = any( + function_counts.get( ( site[0], name ), 0 ) > 0 for name in names + ) + function_sites[site] = { + "covered": covered, + "names": sorted( names ), + } + return { + "line_counts": line_counts, + "branch_outcomes": branch_outcomes, + "function_sites": function_sites, + } + + +def patch_metric( covered: int, total: int ) -> dict: + return { + "covered": covered, + "not_covered": total - covered, + "total": total, + "percent": round( 100.0 * covered / total, 6 ) if total else None, + } + + +def location_hint( + line_missed: bool, + missed_branches: int, + covered_branches: int, + function_missed: bool, +) -> str: + if line_missed and function_missed: + if missed_branches: + return "Call this function in a coverage smoke, then exercise its decision outcomes." + return "Add a coverage smoke that calls this function entry." + if line_missed: + if missed_branches: + return "Reach this executable line, then exercise its decision outcomes." + return "Add a coverage smoke that reaches this executable line." + if missed_branches: + if covered_branches: + return "Add cases that exercise the remaining decision outcome(s)." + return "Add cases that take each unobserved decision outcome." + if function_missed: + return "Add a coverage smoke that calls this function entry." + return "Add a focused coverage smoke for this location." + + +def build_patch_report( changed_files: list[dict], lcov: dict ) -> dict: + line_counts = lcov["line_counts"] + branch_outcomes = lcov["branch_outcomes"] + function_sites = lcov["function_sites"] + file_results = [] + locations = [] + + for changed_file in changed_files: + path = changed_file["path"] + changed = set( changed_file["lines"] ) + file_lines = { + line: count + for ( source_path, line ), count in line_counts.items() + if source_path == path + } + file_branches = { + key: covered + for key, covered in branch_outcomes.items() + if key[0] == path + } + file_functions = { + line: details + for ( source_path, line ), details in function_sites.items() + if source_path == path + } + line_metric = patch_metric( + sum( count > 0 for count in file_lines.values() ), len( file_lines ) + ) + function_metric = patch_metric( + sum( details["covered"] for details in file_functions.values() ), + len( file_functions ), + ) + branch_metric = patch_metric( + sum( file_branches.values() ), len( file_branches ) + ) + file_results.append( + { + "path": path, + "old_path": changed_file["old_path"], + "status": changed_file["status"], + "changed_lines": len( changed ), + "non_instrumented_changed_lines": len( changed - set( file_lines ) ), + "metrics": { + "executable_lines": line_metric, + "function_start_sites": function_metric, + "native_branch_outcomes": branch_metric, + }, + } + ) + + candidate_lines = set( file_lines ) | set( file_functions ) + candidate_lines.update( key[1] for key in file_branches ) + for line in candidate_lines: + executable = line in file_lines + line_covered = file_lines.get( line, 0 ) > 0 if executable else None + branch_values = [ + covered for key, covered in file_branches.items() if key[1] == line + ] + covered_branches = sum( branch_values ) + missed_branches = len( branch_values ) - covered_branches + function = file_functions.get( line ) + function_missed = function is not None and not function["covered"] + line_missed = executable and not line_covered + if not ( line_missed or missed_branches or function_missed ): + continue + names = function["names"] if function is not None else [] + locations.append( + { + "path": path, + "line": line, + "executable_line": executable, + "line_covered": line_covered, + "native_branch_outcomes": patch_metric( + covered_branches, len( branch_values ) + ), + "function_start_covered": ( + function["covered"] if function is not None else None + ), + "function_names": names[:MAX_FUNCTION_NAMES], + "additional_function_names": max( + 0, len( names ) - MAX_FUNCTION_NAMES + ), + "hint": location_hint( + line_missed, + missed_branches, + covered_branches, + function_missed, + ), + } + ) + + file_results.sort( + key=lambda result: ( + -result["metrics"]["native_branch_outcomes"]["not_covered"], + -result["metrics"]["executable_lines"]["not_covered"], + -result["metrics"]["function_start_sites"]["not_covered"], + result["path"], + ) + ) + locations.sort( + key=lambda location: ( + -location["native_branch_outcomes"]["not_covered"], + -( location["executable_line"] and not location["line_covered"] ), + -( location["function_start_covered"] is False ), + location["path"], + location["line"], + ) + ) + + executable_total = len( line_counts ) + function_total = len( function_sites ) + branch_total = len( branch_outcomes ) + all_changed_lines = sum( len( record["lines"] ) for record in changed_files ) + return { + "changed_files": len( changed_files ), + "changed_lines": all_changed_lines, + "non_instrumented_changed_lines": all_changed_lines - executable_total, + "metrics": { + "executable_lines": patch_metric( + sum( count > 0 for count in line_counts.values() ), executable_total + ), + "function_start_sites": patch_metric( + sum( details["covered"] for details in function_sites.values() ), + function_total, + ), + "native_branch_outcomes": patch_metric( + sum( branch_outcomes.values() ), branch_total + ), + }, + "top_impacted_files": file_results[:MAX_IMPACTED_FILES], + "top_impacted_locations": locations[:MAX_IMPACTED_LOCATIONS], + } + + +def unavailable_comparison( reason: str, candidate: dict ) -> dict: + return { + "comparable": False, + "reason": reason, + "baseline": None, + "candidate": candidate["metrics"], + "metrics": {}, + "changed_file_metrics": [], + "provenance": None, + } + + +def build_aggregate_comparison( + candidate: dict, + baseline: dict | None, + baseline_error: str | None, + base_sha: str, + head_sha: str, + base_tree: str, + head_tree: str, + changed_files: list[dict], +) -> dict: + if baseline_error is not None: + return unavailable_comparison( baseline_error, candidate ) + if baseline is None: + return unavailable_comparison( + "No exact-base coverage summary was provided.", candidate + ) + if baseline["schema_version"] != 3 or candidate["schema_version"] != 3: + return unavailable_comparison( + "N/A: exact comparison requires schema-3 measurement provenance " + "on both summaries.", + candidate, + ) + + compatibility_fields = ( + ( "scope", "production scopes differ" ), + ( "excluded_regex", "source exclusion contracts differ" ), + ) + for field, reason in compatibility_fields: + if baseline[field] != candidate[field]: + return unavailable_comparison( f"N/A: {reason}.", candidate ) + if baseline["tool"] != candidate["tool"]: + return unavailable_comparison( "N/A: LLVM coverage tools differ.", candidate ) + + baseline_measurement = baseline["measurement"] + candidate_measurement = candidate["measurement"] + if baseline_measurement is None or candidate_measurement is None: + raise ValueError( "schema-3 coverage summaries are missing validated provenance" ) + if baseline_measurement["commit_sha"] != base_sha: + return unavailable_comparison( + "N/A: baseline measurement commit does not match --base-sha.", candidate + ) + if candidate_measurement["commit_sha"] != head_sha: + return unavailable_comparison( + "N/A: candidate measurement commit does not match --head-sha.", candidate + ) + if baseline_measurement["tree_sha"] != base_tree: + return unavailable_comparison( + "N/A: baseline measurement tree does not match the base commit.", candidate + ) + if candidate_measurement["tree_sha"] != head_tree: + return unavailable_comparison( + "N/A: candidate measurement tree does not match the head commit.", candidate + ) + if baseline_measurement["contract_id"] != candidate_measurement["contract_id"]: + return unavailable_comparison( + "N/A: coverage measurement contract IDs differ.", candidate + ) + if ( + baseline_measurement["contract_fingerprint"] + != candidate_measurement["contract_fingerprint"] + ): + return unavailable_comparison( + "N/A: coverage measurement contract fingerprints differ.", candidate + ) + + comparisons = {} + for name in CANONICAL_METRICS: + base_metric = baseline["metrics"][name] + head_metric = candidate["metrics"][name] + comparisons[name] = { + "baseline": base_metric, + "candidate": head_metric, + "covered_delta": head_metric["covered"] - base_metric["covered"], + "not_covered_delta": ( + head_metric["not_covered"] - base_metric["not_covered"] + ), + "total_delta": head_metric["total"] - base_metric["total"], + "percentage_point_delta": round( + head_metric["percent"] - base_metric["percent"], 6 + ), + } + baseline_files = { + source["path"]: source["metrics"] + for source in baseline["per_file_metrics"] + } + candidate_files = { + source["path"]: source["metrics"] + for source in candidate["per_file_metrics"] + } + empty_metric = { "covered": 0, "not_covered": 0, "total": 0, "percent": 0.0 } + changed_file_metrics = [] + for changed_file in changed_files: + path = changed_file["path"] + baseline_path = changed_file["old_path"] or path + baseline_metrics = baseline_files.get( baseline_path, {} ) + candidate_metrics = candidate_files.get( path, {} ) + metric_changes = {} + for name in CANONICAL_METRICS: + base_metric = baseline_metrics.get( name, empty_metric ) + head_metric = candidate_metrics.get( name, empty_metric ) + metric_changes[name] = { + "baseline": base_metric, + "candidate": head_metric, + "covered_delta": head_metric["covered"] - base_metric["covered"], + "not_covered_delta": ( + head_metric["not_covered"] - base_metric["not_covered"] + ), + "total_delta": head_metric["total"] - base_metric["total"], + } + changed_file_metrics.append( + { + "path": path, + "baseline_path": baseline_path, + "metrics": metric_changes, + } + ) + changed_file_metrics.sort( + key=lambda source: ( + -source["metrics"]["branches"]["not_covered_delta"], + -source["metrics"]["lines"]["not_covered_delta"], + source["path"], + ) + ) + return { + "comparable": True, + "reason": "Exact commit, tree, and coverage measurement contract match.", + "baseline": baseline["metrics"], + "candidate": candidate["metrics"], + "metrics": comparisons, + "changed_file_metrics": changed_file_metrics[:MAX_IMPACTED_FILES], + "provenance": { + "baseline_commit_sha": base_sha, + "candidate_commit_sha": head_sha, + "contract_id": candidate_measurement["contract_id"], + "contract_fingerprint": candidate_measurement["contract_fingerprint"], + }, + } + + +def markdown_escape( value: str ) -> str: + escaped = html.escape( value, quote=True ) + return ( + escaped.replace( "|", "|" ) + .replace( "\r", " " ) + .replace( "\n", " " ) + ) + + +def percent_text( metric: dict ) -> str: + if metric["total"] == 0: + return "N/A" + return f"{100.0 * metric['covered'] / metric['total']:.2f}%" + + +def compact_metric_text( metric: dict ) -> str: + if metric["total"] == 0: + return "N/A" + return ( + f"{metric['covered']:,}/{metric['total']:,} " + f"({percent_text( metric )})" + ) + + +def signed_integer( value: int ) -> str: + return f"{value:+,}" + + +def build_markdown( report: dict ) -> str: + aggregate = report["aggregate_comparison"] + patch = report["patch"] + display_names = { + "regions": "Regions", + "functions": "Functions", + "lines": "Lines", + "branches": "Canonical branches", + } + rows = [ + "### Pull-request coverage", + "", + f"Effective source diff: `{report['base_sha'][:12]}` → " + f"`{report['head_sha'][:12]}` · Production scope: " + f"`{markdown_escape( report['scope'] )}`", + "", + "#### Project coverage change", + "", + ] + if aggregate["comparable"]: + rows.extend( + ( + "| Metric | Exact base | Candidate | Coverage change | " + "Covered Δ | Missed Δ | Total Δ |", + "|---|---:|---:|---:|---:|---:|---:|", + ) + ) + for name in DISPLAY_METRICS: + comparison = aggregate["metrics"][name] + rows.append( + f"| {display_names[name]} | " + f"{compact_metric_text( comparison['baseline'] )} | " + f"{compact_metric_text( comparison['candidate'] )} | " + f"{comparison['percentage_point_delta']:+.2f} pp | " + f"{signed_integer( comparison['covered_delta'] )} | " + f"{signed_integer( comparison['not_covered_delta'] )} | " + f"{signed_integer( comparison['total_delta'] )} |" + ) + rows.extend( + ( + "", + "Comparison is exact because commit, tree, and measurement-contract " + "provenance match.", + ) + ) + changed_file_metrics = aggregate["changed_file_metrics"] + if changed_file_metrics: + rows.extend( + ( + "", + "##### Canonical movement in changed files", + "", + "| Source file | Base branches | Candidate branches | " + "Missed branch Δ | Missed line Δ |", + "|---|---:|---:|---:|---:|", + ) + ) + for source in changed_file_metrics: + branch_change = source["metrics"]["branches"] + line_change = source["metrics"]["lines"] + display_path = source["path"] + if source["baseline_path"] != source["path"]: + display_path = f"{source['baseline_path']} → {source['path']}" + rows.append( + f"| {markdown_escape( display_path )} | " + f"{compact_metric_text( branch_change['baseline'] )} | " + f"{compact_metric_text( branch_change['candidate'] )} | " + f"{signed_integer( branch_change['not_covered_delta'] )} | " + f"{signed_integer( line_change['not_covered_delta'] )} |" + ) + rows.extend( + ( + "", + "This is whole-file canonical context, ranked by newly missed " + "branches; the changed-code table below is the hunk-specific signal.", + ) + ) + else: + rows.extend( + ( + "| Comparison | Result | Reason |", + "|---|:---:|---|", + "| Exact base → candidate | ℹ️ N/A | " + f"{markdown_escape( aggregate['reason'] )} |", + ) + ) + + patch_metrics = patch["metrics"] + rows.extend( + ( + "", + "#### Changed-code coverage", + "", + "| Signal | Covered | Missed | Total | Coverage | Interpretation |", + "|---|---:|---:|---:|---:|---|", + f"| Executable changed lines | " + f"{patch_metrics['executable_lines']['covered']:,} | " + f"{patch_metrics['executable_lines']['not_covered']:,} | " + f"{patch_metrics['executable_lines']['total']:,} | " + f"{percent_text( patch_metrics['executable_lines'] )} | " + "Exact LLVM line mapping on added/modified head lines |", + f"| Changed function start sites | " + f"{patch_metrics['function_start_sites']['covered']:,} | " + f"{patch_metrics['function_start_sites']['not_covered']:,} | " + f"{patch_metrics['function_start_sites']['total']:,} | " + f"{percent_text( patch_metrics['function_start_sites'] )} | " + "Advisory; C++ instantiations may share a source definition |", + f"| Native changed branch outcomes | " + f"{patch_metrics['native_branch_outcomes']['covered']:,} | " + f"{patch_metrics['native_branch_outcomes']['not_covered']:,} | " + f"{patch_metrics['native_branch_outcomes']['total']:,} | " + f"{percent_text( patch_metrics['native_branch_outcomes'] )} | " + "Advisory and instantiation-weighted; not the canonical branch gate |", + "", + f"Git identified **{patch['changed_lines']:,}** added/modified head lines " + f"across **{patch['changed_files']:,}** production files. " + f"**{patch['non_instrumented_changed_lines']:,}** changed lines have no " + "LLVM line mapping; these may be comments, declarations, braces, or " + "configuration-dependent code.", + ) + ) + if patch["excluded_changed_files"]: + rows.append( + f"The production coverage contract excludes an additional " + f"**{patch['excluded_changed_lines']:,}** changed lines across " + f"**{patch['excluded_changed_files']:,}** test/example/support files." + ) + + impacted_files = patch["top_impacted_files"] + if impacted_files: + rows.extend( + ( + "", + "#### Changed files with the largest coverage gaps", + "", + "| Source file | Change | Changed lines | Executable lines | " + "Function starts | Native branches |", + "|---|:---:|---:|---:|---:|---:|", + ) + ) + for file_result in impacted_files: + metrics = file_result["metrics"] + rows.append( + f"| {markdown_escape( file_result['path'] )} | " + f"{file_result['status']} | {file_result['changed_lines']:,} | " + f"{compact_metric_text( metrics['executable_lines'] )} | " + f"{compact_metric_text( metrics['function_start_sites'] )} | " + f"{compact_metric_text( metrics['native_branch_outcomes'] )} |" + ) + + impacted_locations = patch["top_impacted_locations"] + rows.extend( ( "", "#### Highest-impact changed locations", "" ) ) + if impacted_locations: + rows.extend( + ( + "| Location | Coverage gap | Suggested test |", + "|---|---|---|", + ) + ) + for location in impacted_locations: + gaps = [] + if location["executable_line"] and not location["line_covered"]: + gaps.append( "executable line not hit" ) + missed_branches = location["native_branch_outcomes"]["not_covered"] + if missed_branches: + gaps.append( + f"{missed_branches:,} native branch outcome" + f"{'s' if missed_branches != 1 else ''} missed" + ) + if location["function_start_covered"] is False: + function_detail = "function start not entered" + if location["function_names"]: + names = ", ".join( + f"{markdown_escape( name )}" + for name in location["function_names"] + ) + function_detail += f" ({names}" + if location["additional_function_names"]: + function_detail += ( + f", +{location['additional_function_names']:,} more" + ) + function_detail += ")" + gaps.append( function_detail ) + rows.append( + f"| {markdown_escape( location['path'] )}:" + f"{location['line']} | {'; '.join( gaps )} | " + f"{markdown_escape( location['hint'] )} |" + ) + else: + rows.append( + "No uncovered instrumented changed locations were found. If the patch " + "has no executable changed lines, this section is informationally empty." + ) + + rows.extend( + ( + "", + "Changed-line coverage is exact. Function-start and native branch details " + "are diagnostics because LLVM 20's detailed export retains C++ template " + "and inline instantiations.", + "", + "This report is advisory and does not enforce a threshold.", + ) + ) + return "\n".join( rows ) + "\n" + + +def analyze( + repository: pathlib.Path, + base_sha: str, + head_sha: str, + native_info: pathlib.Path, + candidate_summary_path: pathlib.Path, + baseline_summary_path: pathlib.Path | None = None, +) -> tuple[dict, str]: + repository = validate_repository( repository ) + base_sha = validate_commit( repository, base_sha, "--base-sha" ) + head_sha = validate_commit( repository, head_sha, "--head-sha" ) + base_tree = commit_tree( repository, base_sha ) + head_tree = commit_tree( repository, head_sha ) + native_info = checked_input_file( native_info, "--native-info" ) + candidate_summary_path = checked_input_file( + candidate_summary_path, "--candidate-summary" + ) + candidate = validate_summary( + read_json( candidate_summary_path, "candidate summary" ), + "candidate summary", + ) + if candidate["scope"] != PRODUCTION_SCOPE: + raise ValueError( + f"candidate summary scope {candidate['scope']!r} does not match " + f"{PRODUCTION_SCOPE!r}" + ) + + baseline = None + baseline_error = None + if baseline_summary_path is not None: + try: + baseline_summary_path = checked_input_file( + baseline_summary_path, "--baseline-summary" + ) + baseline = validate_summary( + read_json( baseline_summary_path, "baseline summary" ), + "baseline summary", + ) + except ValueError as error: + baseline_error = f"N/A: baseline summary is invalid: {error}" + + changed_files = changed_line_ranges( repository, base_sha, head_sha ) + try: + exclusion_pattern = re.compile( candidate["excluded_regex"] ) + except re.error as error: + raise ValueError( + f"candidate summary excluded_regex is invalid: {error}" + ) from error + included_changed_files = [] + excluded_changed_files = [] + for changed_file in changed_files: + absolute_source = ( repository / changed_file["path"] ).as_posix() + if exclusion_pattern.search( absolute_source ) is not None: + excluded_changed_files.append( changed_file ) + else: + included_changed_files.append( changed_file ) + changed_files = included_changed_files + lcov = parse_native_lcov( native_info, repository, changed_files ) + patch = build_patch_report( changed_files, lcov ) + patch["excluded_changed_files"] = len( excluded_changed_files ) + patch["excluded_changed_lines"] = sum( + len( changed_file["lines"] ) for changed_file in excluded_changed_files + ) + aggregate = build_aggregate_comparison( + candidate, + baseline, + baseline_error, + base_sha, + head_sha, + base_tree, + head_tree, + changed_files, + ) + report = { + "schema_version": REPORT_SCHEMA_VERSION, + "scope": PRODUCTION_SCOPE, + "base_sha": base_sha, + "head_sha": head_sha, + "base_tree_sha": base_tree, + "head_tree_sha": head_tree, + "candidate_summary_schema_version": candidate["schema_version"], + "aggregate_comparison": aggregate, + "patch": patch, + } + return report, build_markdown( report ) + + +def checked_output_path( + path: pathlib.Path, + inputs: set[pathlib.Path], + description: str, +) -> pathlib.Path: + if path.is_symlink(): + raise ValueError( f"{description} must not be a symbolic link: {path}" ) + absolute = path.absolute() + if absolute in inputs: + raise ValueError( f"{description} must not overwrite an input file" ) + return absolute + + +def write_atomic( path: pathlib.Path, contents: str ) -> None: + path.parent.mkdir( parents=True, exist_ok=True ) + if path.is_symlink(): + raise ValueError( f"refusing to replace symbolic link: {path}" ) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary_path = pathlib.Path( temporary_name ) + try: + # The CI analyzer runs as root in Docker while the GitHub runner reads + # these files from a bind mount. mkstemp defaults to 0600, so make the + # final compact report deliberately host-readable before replacement. + os.fchmod( descriptor, 0o644 ) + with os.fdopen( descriptor, "w", encoding="utf-8" ) as stream: + stream.write( contents ) + os.replace( temporary_path, path ) + except BaseException: + temporary_path.unlink( missing_ok=True ) + raise + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Report advisory source coverage for a Git pull-request diff." + ) + parser.add_argument( "--repository", required=True, type=pathlib.Path ) + parser.add_argument( "--base-sha", required=True ) + parser.add_argument( "--head-sha", required=True ) + parser.add_argument( "--native-info", required=True, type=pathlib.Path ) + parser.add_argument( "--candidate-summary", required=True, type=pathlib.Path ) + parser.add_argument( "--baseline-summary", type=pathlib.Path ) + parser.add_argument( "--output-json", type=pathlib.Path ) + parser.add_argument( "--output-markdown", type=pathlib.Path ) + args = parser.parse_args() + + try: + input_paths = { + checked_input_file( args.native_info, "--native-info" ), + checked_input_file( args.candidate_summary, "--candidate-summary" ), + } + if args.baseline_summary is not None: + # A malformed baseline remains advisory, but an output must never be + # allowed to overwrite the caller-provided path. + input_paths.add( args.baseline_summary.absolute() ) + output_json = ( + checked_output_path( args.output_json, input_paths, "--output-json" ) + if args.output_json is not None + else None + ) + output_markdown = ( + checked_output_path( + args.output_markdown, input_paths, "--output-markdown" + ) + if args.output_markdown is not None + else None + ) + if output_json is not None and output_json == output_markdown: + raise ValueError( "--output-json and --output-markdown must differ" ) + + report, markdown = analyze( + args.repository, + args.base_sha, + args.head_sha, + args.native_info, + args.candidate_summary, + args.baseline_summary, + ) + json_text = json.dumps( + report, indent=2, sort_keys=True, ensure_ascii=False + ) + "\n" + if output_json is not None: + write_atomic( output_json, json_text ) + if output_markdown is not None: + write_atomic( output_markdown, markdown ) + else: + print( markdown, end="" ) + return 0 + except ( OSError, ValueError ) as error: + print( f"PR coverage comparison error: {error}", file=sys.stderr ) + return 2 + + +if __name__ == "__main__": + sys.exit( main() ) diff --git a/scripts/fetch_coverage_baseline.py b/scripts/fetch_coverage_baseline.py new file mode 100755 index 00000000000..f737735dfa3 --- /dev/null +++ b/scripts/fetch_coverage_baseline.py @@ -0,0 +1,802 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +"""Fetch an exact-base artifact published by a successful coverage job.""" + +import argparse +import http.client +import io +import json +import os +import pathlib +import re +import stat +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +import zipfile + + +SCRIPT_DIRECTORY = pathlib.Path( __file__ ).resolve().parent +if str( SCRIPT_DIRECTORY ) not in sys.path: + sys.path.insert( 0, str( SCRIPT_DIRECTORY ) ) +import check_coverage_thresholds as coverage_contract +import zlib + + +API_ROOT = "https://api.github.com" +API_VERSION = "2022-11-28" +USER_AGENT = "geos-coverage-baseline-fetch/1" +DOWNLOAD_LIMIT = 10 * 1024 * 1024 +JSON_RESPONSE_LIMIT = 2 * 1024 * 1024 +PER_PAGE = 100 +MAX_PAGES = 10 +REQUEST_TIMEOUT_SECONDS = 30 +READ_CHUNK_SIZE = 64 * 1024 +EXPECTED_FILES = frozenset( + ( "coverage-summary.json", "llvm-summary.json", "toolchain.txt" ) +) +REQUIRED_FILES = frozenset( ( "coverage-summary.json", ) ) +STATUS_FILE = "coverage-baseline-status.json" +SHA_RE = re.compile( r"^[0-9a-fA-F]{40}$" ) +REPOSITORY_PART_RE = re.compile( r"^[A-Za-z0-9_.-]+$" ) +WORKFLOW_RE = re.compile( r"^[A-Za-z0-9_.-]+$" ) +ENVIRONMENT_NAME_RE = re.compile( r"^[A-Za-z_][A-Za-z0-9_]*$" ) +TOKEN_RE = re.compile( r"^[\x21-\x7e]+$" ) +ALLOWED_ZIP_CONTENT_TYPES = frozenset( + ( "application/octet-stream", "application/x-zip-compressed", "application/zip" ) +) +SENSITIVE_REDIRECT_HEADERS = frozenset( + ( + "authorization", + "cookie", + "cookie2", + "proxy-authorization", + "x-github-api-version", + ) +) + + +class BaselineFetchError( Exception ): + """A malformed input, unsafe artifact, or GitHub API failure.""" + + +class BaselineMissing( Exception ): + """The exact-base run or artifact is not available.""" + + def __init__( self, reason_code: str, message: str ) -> None: + super().__init__( message ) + self.reason_code = reason_code + + +def _https_url_parts( url: str, description: str ): + if not isinstance( url, str ) or any( + ord( character ) < 32 or ord( character ) == 127 for character in url + ): + raise BaselineFetchError( f"{description} is not a safe HTTPS URL" ) + try: + parsed = urllib.parse.urlsplit( url ) + hostname = parsed.hostname + port = parsed.port + username = parsed.username + password = parsed.password + except ( TypeError, ValueError ) as error: + raise BaselineFetchError( f"{description} is not a safe HTTPS URL" ) from error + if ( + parsed.scheme != "https" + or not hostname + or username is not None + or password is not None + ): + raise BaselineFetchError( f"{description} is not a safe HTTPS URL" ) + return parsed, hostname.lower(), 443 if port is None else port + + +class SafeRedirectHandler( urllib.request.HTTPRedirectHandler ): + """Allow HTTPS redirects without forwarding credentials across origins.""" + + def redirect_request( self, request, file_pointer, code, message, headers, url ): + del file_pointer, code, message, headers + try: + redirected_url = urllib.parse.urljoin( request.full_url, url ) + except ( TypeError, ValueError ) as error: + raise BaselineFetchError( + "GitHub redirect URL is not a safe HTTPS URL" + ) from error + old, old_hostname, old_port = _https_url_parts( + request.full_url, "GitHub request URL" + ) + _, new_hostname, new_port = _https_url_parts( + redirected_url, "GitHub redirect URL" + ) + + redirected_headers = {} + same_origin = ( old.scheme, old_hostname, old_port ) == ( + "https", + new_hostname, + new_port, + ) + for name, value in request.header_items(): + if not same_origin and name.lower() in SENSITIVE_REDIRECT_HEADERS: + continue + redirected_headers[name] = value + return urllib.request.Request( + redirected_url, + headers=redirected_headers, + origin_req_host=request.origin_req_host, + unverifiable=True, + method="GET", + ) + + +def _default_opener(): + return urllib.request.build_opener( SafeRedirectHandler() ) + + +def _require_content_length( headers, limit: int ) -> None: + value = headers.get( "Content-Length" ) + if value is None: + return + if not isinstance( value, str ) or not re.fullmatch( r"[0-9]+", value ): + raise BaselineFetchError( "HTTP response has an invalid Content-Length" ) + length = int( value, 10 ) + if length > limit: + raise BaselineFetchError( + f"HTTP response exceeds the {limit}-byte download limit" + ) + + +def _read_bounded( response, limit: int ) -> bytes: + _require_content_length( response.headers, limit ) + chunks = [] + size = 0 + while True: + chunk = response.read( min( READ_CHUNK_SIZE, limit + 1 - size ) ) + if not isinstance( chunk, bytes ): + raise BaselineFetchError( "HTTP response returned non-byte content" ) + if not chunk: + return b"".join( chunks ) + chunks.append( chunk ) + size += len( chunk ) + if size > limit: + raise BaselineFetchError( + f"HTTP response exceeds the {limit}-byte download limit" + ) + + +def _content_type( headers ) -> str: + value = headers.get( "Content-Type", "" ) + return value.split( ";", maxsplit=1 )[0].strip().lower() + + +class GitHubClient: + def __init__( self, token: str, opener=None ) -> None: + if not isinstance( token, str ) or not TOKEN_RE.fullmatch( token ): + raise BaselineFetchError( "GitHub token is empty or invalid" ) + self._token = token + self._opener = opener if opener is not None else _default_opener() + + def _request( self, path: str, query=None ): + if not path.startswith( "/" ): + raise BaselineFetchError( "GitHub API path must be absolute" ) + url = f"{API_ROOT}{path}" + if query: + url = f"{url}?{urllib.parse.urlencode( query )}" + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self._token}", + "User-Agent": USER_AGENT, + "X-GitHub-Api-Version": API_VERSION, + }, + method="GET", + ) + try: + return self._opener.open( request, timeout=REQUEST_TIMEOUT_SECONDS ) + except urllib.error.HTTPError: + raise + except BaselineFetchError: + raise + except ( + OSError, + urllib.error.URLError, + http.client.HTTPException, + ) as error: + raise BaselineFetchError( f"GitHub request failed: {error}" ) from error + + def get_json( self, path: str, query=None ) -> dict: + try: + response = self._request( path, query ) + with response: + status = getattr( response, "status", response.getcode() ) + if status != 200: + raise BaselineFetchError( + f"GitHub JSON request returned HTTP {status}" + ) + _, final_hostname, final_port = _https_url_parts( + response.geturl(), "GitHub JSON response URL" + ) + if final_hostname != "api.github.com" or final_port != 443: + raise BaselineFetchError( "GitHub JSON request left api.github.com" ) + if _content_type( response.headers ) not in ( + "application/json", + "application/vnd.github+json", + ): + raise BaselineFetchError( + "GitHub JSON response has an unexpected Content-Type" + ) + payload = _read_bounded( response, JSON_RESPONSE_LIMIT ) + except urllib.error.HTTPError as error: + raise BaselineFetchError( + f"GitHub JSON request returned HTTP {error.code}" + ) from error + except BaselineFetchError: + raise + except ( + OSError, + urllib.error.URLError, + http.client.HTTPException, + ) as error: + raise BaselineFetchError( + f"GitHub JSON response failed: {error}" + ) from error + + try: + document = json.loads( payload.decode( "utf-8" ) ) + except ( UnicodeDecodeError, ValueError, RecursionError ) as error: + raise BaselineFetchError( "GitHub returned malformed JSON" ) from error + if not isinstance( document, dict ): + raise BaselineFetchError( "GitHub JSON response must be an object" ) + return document + + def download_artifact( self, repository_path: str, artifact_id: int ) -> bytes: + path = f"{repository_path}/actions/artifacts/{artifact_id}/zip" + try: + response = self._request( path ) + with response: + status = getattr( response, "status", response.getcode() ) + if status in ( 404, 410 ): + raise BaselineMissing( + "artifact_download_missing", + "the exact-base coverage artifact disappeared or expired", + ) + if status != 200: + raise BaselineFetchError( + f"artifact download returned HTTP {status}" + ) + _https_url_parts( response.geturl(), "artifact download URL" ) + if _content_type( response.headers ) not in ALLOWED_ZIP_CONTENT_TYPES: + raise BaselineFetchError( + "artifact download has an unexpected Content-Type" + ) + return _read_bounded( response, DOWNLOAD_LIMIT ) + except urllib.error.HTTPError as error: + if error.code in ( 404, 410 ): + raise BaselineMissing( + "artifact_download_missing", + "the exact-base coverage artifact disappeared or expired", + ) from error + raise BaselineFetchError( + f"artifact download returned HTTP {error.code}" + ) from error + except BaselineFetchError: + raise + except ( + OSError, + urllib.error.URLError, + http.client.HTTPException, + ) as error: + raise BaselineFetchError( + f"artifact download response failed: {error}" + ) from error + + +def validate_repository( repository: str ) -> tuple[str, str]: + parts = repository.split( "/" ) + if ( + len( parts ) != 2 + or not parts[0] + or not parts[1] + or any( part in ( ".", ".." ) for part in parts ) + or not all( REPOSITORY_PART_RE.fullmatch( part ) for part in parts ) + ): + raise BaselineFetchError( "repository must have the form owner/repo" ) + return parts[0], parts[1] + + +def validate_workflow( workflow: str ) -> str: + if workflow in ( ".", ".." ) or not WORKFLOW_RE.fullmatch( workflow ): + raise BaselineFetchError( "workflow must be a workflow filename or numeric ID" ) + return workflow + + +def validate_sha( revision: str, description: str = "base SHA" ) -> str: + if not isinstance( revision, str ) or not SHA_RE.fullmatch( revision ): + raise BaselineFetchError( f"{description} must be exactly 40 hexadecimal digits" ) + return revision.lower() + + +def validate_artifact_prefix( prefix: str ) -> str: + if ( + not prefix + or len( prefix ) > 160 + or any( not character.isprintable() for character in prefix ) + or "/" in prefix + or "\\" in prefix + ): + raise BaselineFetchError( "artifact name prefix is invalid" ) + return prefix + + +def repository_api_path( repository: str ) -> str: + owner, name = validate_repository( repository ) + return "/repos/{}/{}".format( + urllib.parse.quote( owner, safe="" ), urllib.parse.quote( name, safe="" ) + ) + + +def paginated_items( + client: GitHubClient, + path: str, + list_key: str, + query: dict, +): + expected_total = None + yielded = 0 + for page in range( 1, MAX_PAGES + 1 ): + page_query = dict( query ) + page_query.update( { "page": page, "per_page": PER_PAGE } ) + document = client.get_json( path, page_query ) + total = document.get( "total_count" ) + if isinstance( total, bool ) or not isinstance( total, int ) or total < 0: + raise BaselineFetchError( "GitHub paginated response has invalid total_count" ) + if expected_total is None: + expected_total = total + elif total != expected_total: + raise BaselineFetchError( "GitHub paginated total_count changed between pages" ) + items = document.get( list_key ) + if not isinstance( items, list ): + raise BaselineFetchError( + f"GitHub paginated response is missing the {list_key} array" + ) + if len( items ) > PER_PAGE: + raise BaselineFetchError( + "GitHub paginated response exceeds the requested page size" + ) + yielded += len( items ) + if yielded > expected_total: + raise BaselineFetchError( + "GitHub paginated response contains more entries than total_count" + ) + for item in items: + yield item + if yielded == expected_total: + return + if len( items ) < PER_PAGE: + raise BaselineFetchError( + "GitHub paginated response ended before total_count" + ) + raise BaselineFetchError( "GitHub paginated response exceeds the page limit" ) + + +def find_exact_run( + client: GitHubClient, repository_path: str, workflow: str, base_sha: str +) -> dict: + workflow_component = urllib.parse.quote( validate_workflow( workflow ), safe="" ) + path = f"{repository_path}/actions/workflows/{workflow_component}/runs" + query = { + "branch": "develop", + "event": "push", + "head_sha": base_sha, + # The exact baseline artifact is uploaded only after the coverage job + # succeeds. The monolithic workflow may still fail for an unrelated + # CUDA/integrated job, so workflow-level conclusion is not the trust + # boundary for this artifact. + "status": "completed", + } + matches = [] + for run in paginated_items( client, path, "workflow_runs", query ): + if not isinstance( run, dict ): + raise BaselineFetchError( "GitHub workflow run entry must be an object" ) + head_sha = run.get( "head_sha" ) + if not isinstance( head_sha, str ) or not SHA_RE.fullmatch( head_sha ): + raise BaselineFetchError( "GitHub workflow run has an invalid head_sha" ) + if head_sha.lower() != base_sha: + continue + if ( + run.get( "event" ) != "push" + or run.get( "head_branch" ) != "develop" + or run.get( "status" ) != "completed" + ): + continue + run_id = run.get( "id" ) + attempt = run.get( "run_attempt", 1 ) + if ( + isinstance( run_id, bool ) + or not isinstance( run_id, int ) + or run_id < 1 + or isinstance( attempt, bool ) + or not isinstance( attempt, int ) + or attempt < 1 + ): + raise BaselineFetchError( "GitHub workflow run has an invalid identity" ) + matches.append( ( attempt, run_id, run ) ) + + if not matches: + raise BaselineMissing( + "workflow_run_missing", + f"no completed develop push run exists for exact base {base_sha}", + ) + matches.sort( key=lambda candidate: ( candidate[1], candidate[0] ), reverse=True ) + return matches[0][2] + + +def find_exact_artifact( + client: GitHubClient, + repository_path: str, + run_id: int, + expected_name: str, +) -> dict: + path = f"{repository_path}/actions/runs/{run_id}/artifacts" + exact = [] + for artifact in paginated_items( client, path, "artifacts", {} ): + if not isinstance( artifact, dict ): + raise BaselineFetchError( "GitHub artifact entry must be an object" ) + if artifact.get( "name" ) != expected_name: + continue + expired = artifact.get( "expired" ) + if not isinstance( expired, bool ): + raise BaselineFetchError( "GitHub artifact has an invalid expired value" ) + artifact_id = artifact.get( "id" ) + if ( + isinstance( artifact_id, bool ) + or not isinstance( artifact_id, int ) + or artifact_id < 1 + ): + raise BaselineFetchError( "GitHub artifact has an invalid ID" ) + exact.append( artifact ) + + available = [artifact for artifact in exact if not artifact["expired"]] + if len( available ) > 1: + raise BaselineFetchError( "multiple unexpired exact-base artifacts were found" ) + if available: + return available[0] + if exact: + raise BaselineMissing( + "artifact_expired", f"exact-base artifact {expected_name} has expired" + ) + raise BaselineMissing( + "artifact_missing", f"exact-base artifact {expected_name} was not found" + ) + + +def _is_regular_zip_entry( entry: zipfile.ZipInfo ) -> bool: + if entry.is_dir(): + return False + if entry.create_system == 3: + mode = entry.external_attr >> 16 + file_type = stat.S_IFMT( mode ) + return file_type in ( 0, stat.S_IFREG ) + if entry.create_system == 0: + dos_attributes = entry.external_attr & 0xFF + return not dos_attributes & 0x18 # Neither a volume label nor a directory. + return False + + +def validate_summary_revision( payload: bytes, base_sha: str ) -> None: + try: + document = json.loads( payload.decode( "utf-8" ) ) + except ( UnicodeDecodeError, ValueError, RecursionError ) as error: + raise BaselineFetchError( "coverage-summary.json is not valid UTF-8 JSON" ) from error + if not isinstance( document, dict ): + raise BaselineFetchError( "coverage-summary.json must contain an object" ) + schema_version = document.get( "schema_version" ) + if ( + isinstance( schema_version, bool ) + or not isinstance( schema_version, int ) + or schema_version < 1 + ): + raise BaselineFetchError( "coverage-summary.json has an invalid schema_version" ) + + if schema_version == coverage_contract.COVERAGE_SUMMARY_SCHEMA_VERSION: + try: + document = coverage_contract.validate_summary( document ) + except ValueError as error: + raise BaselineFetchError( + f"coverage-summary.json schema 3 contract is invalid: {error}" + ) from error + elif schema_version > coverage_contract.COVERAGE_SUMMARY_SCHEMA_VERSION: + raise BaselineFetchError( + f"coverage-summary.json schema {schema_version} is unsupported" + ) + + revisions = [] + measurement = document.get( "measurement" ) + if schema_version >= 3: + if not isinstance( measurement, dict ) or "commit_sha" not in measurement: + raise BaselineFetchError( + "coverage-summary.json schema 3+ requires measurement.commit_sha" + ) + if measurement is not None: + if not isinstance( measurement, dict ): + raise BaselineFetchError( + "coverage-summary.json measurement must be an object" + ) + if "commit_sha" in measurement: + revisions.append( ( "measurement.commit_sha", measurement["commit_sha"] ) ) + + source = document.get( "source" ) + if source is not None: + if not isinstance( source, dict ): + raise BaselineFetchError( "coverage-summary.json source must be an object" ) + for key in ( "commit", "revision" ): + if key in source: + revisions.append( ( f"source.{key}", source[key] ) ) + for key in ( "source_revision", "revision" ): + if key in document: + revisions.append( ( key, document[key] ) ) + + for description, revision in revisions: + validated = validate_sha( revision, f"coverage summary {description}" ) + if validated != base_sha: + raise BaselineFetchError( + f"coverage summary {description} does not match the requested base SHA" + ) + + +def validate_optional_json( filename: str, payload: bytes ) -> None: + try: + document = json.loads( payload.decode( "utf-8" ) ) + except ( UnicodeDecodeError, ValueError, RecursionError ) as error: + raise BaselineFetchError( f"{filename} is not valid UTF-8 JSON" ) from error + if not isinstance( document, dict ): + raise BaselineFetchError( f"{filename} must contain a JSON object" ) + + +def read_safe_archive( archive: bytes, base_sha: str ) -> dict[str, bytes]: + if len( archive ) > DOWNLOAD_LIMIT: + raise BaselineFetchError( "coverage artifact archive exceeds the download limit" ) + try: + with zipfile.ZipFile( io.BytesIO( archive ) ) as bundle: + entries = bundle.infolist() + if not entries: + raise BaselineFetchError( "coverage artifact ZIP is empty" ) + if len( entries ) > len( EXPECTED_FILES ): + raise BaselineFetchError( + "coverage artifact ZIP contains too many entries" + ) + result = {} + total_size = 0 + for entry in entries: + name = entry.filename + if ( + not isinstance( name, str ) + or entry.orig_filename != name + or name not in EXPECTED_FILES + or pathlib.PurePosixPath( name ).parts != ( name, ) + or "\\" in name + ): + raise BaselineFetchError( + f"coverage artifact contains unexpected entry {name!r}" + ) + if name in result: + raise BaselineFetchError( + f"coverage artifact contains duplicate entry {name}" + ) + if not _is_regular_zip_entry( entry ): + raise BaselineFetchError( + f"coverage artifact entry {name} is not a regular file" + ) + if entry.flag_bits & 0x1: + raise BaselineFetchError( + f"coverage artifact entry {name} is encrypted" + ) + if entry.compress_type not in ( zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED ): + raise BaselineFetchError( + f"coverage artifact entry {name} uses an unsupported compression" + ) + if entry.file_size < 0 or entry.file_size > DOWNLOAD_LIMIT: + raise BaselineFetchError( + f"coverage artifact entry {name} exceeds the extraction limit" + ) + total_size += entry.file_size + if total_size > DOWNLOAD_LIMIT: + raise BaselineFetchError( + "coverage artifact uncompressed content exceeds the extraction limit" + ) + with bundle.open( entry, "r" ) as stream: + payload = stream.read( entry.file_size + 1 ) + if len( payload ) != entry.file_size: + raise BaselineFetchError( + f"coverage artifact entry {name} has an inconsistent size" + ) + result[name] = payload + except BaselineFetchError: + raise + except ( + OSError, + EOFError, + RuntimeError, + ValueError, + zipfile.BadZipFile, + zipfile.LargeZipFile, + zlib.error, + ) as error: + raise BaselineFetchError( f"coverage artifact is not a safe ZIP: {error}" ) from error + + missing = REQUIRED_FILES - result.keys() + if missing: + raise BaselineFetchError( + "coverage artifact is missing required file coverage-summary.json" + ) + validate_summary_revision( result["coverage-summary.json"], base_sha ) + if "llvm-summary.json" in result: + validate_optional_json( "llvm-summary.json", result["llvm-summary.json"] ) + return result + + +def prepare_output_directory( output_dir: pathlib.Path ) -> pathlib.Path: + try: + if output_dir.is_symlink(): + raise BaselineFetchError( "output directory cannot be a symbolic link" ) + output_dir.mkdir( parents=True, exist_ok=True ) + if output_dir.is_symlink() or not output_dir.is_dir(): + raise BaselineFetchError( "output directory is not a safe directory" ) + output_dir = output_dir.resolve( strict=True ) + for filename in ( *EXPECTED_FILES, STATUS_FILE ): + path = output_dir / filename + if os.path.lexists( path ): + if path.is_dir() and not path.is_symlink(): + raise BaselineFetchError( + f"cannot replace directory at output path {filename}" + ) + path.unlink() + except BaselineFetchError: + raise + except OSError as error: + raise BaselineFetchError( f"cannot prepare output directory: {error}" ) from error + return output_dir + + +def atomic_write( path: pathlib.Path, payload: bytes ) -> None: + temporary_path = None + try: + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary_path = pathlib.Path( temporary_name ) + with os.fdopen( descriptor, "wb" ) as stream: + stream.write( payload ) + stream.flush() + os.fsync( stream.fileno() ) + os.chmod( temporary_path, 0o644 ) + os.replace( temporary_path, path ) + temporary_path = None + except OSError as error: + raise BaselineFetchError( f"cannot write {path}: {error}" ) from error + finally: + if temporary_path is not None: + try: + temporary_path.unlink( missing_ok=True ) + except OSError: + pass + + +def write_status( output_dir: pathlib.Path, document: dict ) -> None: + payload = ( json.dumps( document, indent=2, sort_keys=True ) + "\n" ).encode( + "utf-8" + ) + atomic_write( output_dir / STATUS_FILE, payload ) + + +def fetch_baseline( + client: GitHubClient, + repository: str, + workflow: str, + base_sha: str, + artifact_prefix: str, +) -> tuple[dict[str, bytes], dict]: + repository_path = repository_api_path( repository ) + workflow = validate_workflow( workflow ) + base_sha = validate_sha( base_sha ) + artifact_prefix = validate_artifact_prefix( artifact_prefix ) + artifact_name = f"{artifact_prefix}{base_sha}" + if len( artifact_name ) > 255: + raise BaselineFetchError( "exact artifact name is too long" ) + + run = find_exact_run( client, repository_path, workflow, base_sha ) + artifact = find_exact_artifact( + client, repository_path, run["id"], artifact_name + ) + archive = client.download_artifact( repository_path, artifact["id"] ) + files = read_safe_archive( archive, base_sha ) + metadata = { + "artifact_id": artifact["id"], + "artifact_name": artifact_name, + "base_sha": base_sha, + "repository": repository, + "run_id": run["id"], + "schema_version": 1, + "status": "found", + "workflow": workflow, + } + return files, metadata + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Fetch the exact develop-base LLVM coverage artifact" + ) + parser.add_argument( "--repository", required=True ) + parser.add_argument( "--workflow", required=True ) + parser.add_argument( "--base-sha", required=True ) + parser.add_argument( "--artifact-name-prefix", required=True ) + parser.add_argument( "--token-env", default="GITHUB_TOKEN" ) + parser.add_argument( "--output-dir", required=True, type=pathlib.Path ) + return parser + + +def main( argv=None, environ=None, opener=None ) -> int: + args = build_parser().parse_args( argv ) + environment = os.environ if environ is None else environ + output_dir = None + try: + validate_repository( args.repository ) + validate_workflow( args.workflow ) + base_sha = validate_sha( args.base_sha ) + artifact_prefix = validate_artifact_prefix( args.artifact_name_prefix ) + if not ENVIRONMENT_NAME_RE.fullmatch( args.token_env ): + raise BaselineFetchError( "token environment variable name is invalid" ) + token = environment.get( args.token_env, "" ) + if not isinstance( token, str ) or not TOKEN_RE.fullmatch( token ): + raise BaselineFetchError( + f"GitHub token environment variable {args.token_env} " + "is missing or empty, or contains unsafe characters" + ) + output_dir = prepare_output_directory( args.output_dir ) + client = GitHubClient( token, opener=opener ) + files, status = fetch_baseline( + client, + args.repository, + args.workflow, + base_sha, + artifact_prefix, + ) + for filename in sorted( files ): + atomic_write( output_dir / filename, files[filename] ) + write_status( output_dir, status ) + print( + f"Fetched {status['artifact_name']} from workflow run {status['run_id']}." + ) + return 0 + except BaselineMissing as error: + if output_dir is not None: + try: + write_status( + output_dir, + { + "artifact_name": f"{args.artifact_name_prefix}{base_sha}", + "base_sha": base_sha, + "reason": str( error ), + "reason_code": error.reason_code, + "repository": args.repository, + "schema_version": 1, + "status": "missing", + "workflow": args.workflow, + }, + ) + except BaselineFetchError as status_error: + print( f"coverage baseline error: {status_error}", file=sys.stderr ) + return 2 + print( f"coverage baseline unavailable: {error}", file=sys.stderr ) + return 3 + except BaselineFetchError as error: + print( f"coverage baseline error: {error}", file=sys.stderr ) + return 2 + + +if __name__ == "__main__": + sys.exit( main() ) diff --git a/scripts/llvm_source_branch_coverage.sh b/scripts/llvm_source_branch_coverage.sh new file mode 100755 index 00000000000..433ed62cfee --- /dev/null +++ b/scripts/llvm_source_branch_coverage.sh @@ -0,0 +1,770 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-only +# +# Merge Clang source-coverage profiles and report two production branch metrics: +# +# canonical LLVM's CoverageReport summary for the compiled configuration. +# native Every LCOV source-branch outcome emitted by llvm-cov. Template +# and inline-function instantiations can appear more than once. +# +# All metrics include source files below src/coreComponents, excluding +# test/support directory names listed in production_exclude_regex below. + +set -euo pipefail + +# Coverage mapping never needs distribution debug packages. Disabling the +# host's optional debuginfod setting keeps reports local and reproducible. +export DEBUGINFOD_URLS= + +usage() +{ + cat <<'EOF' +Usage: scripts/llvm_source_branch_coverage.sh BUILD_DIR PROFILE_DIR [OUTPUT_DIR] + +BUILD_DIR A CPU shared-library build configured with + GEOS_ENABLE_LLVM_SOURCE_COVERAGE=ON. +PROFILE_DIR Directory containing the *.profraw files from the test run. +OUTPUT_DIR Report directory (default: BUILD_DIR/llvm-source-coverage). + +Environment overrides: + LLVM_COV llvm-cov executable (default: compiler-matched llvm-cov) + LLVM_PROFDATA llvm-profdata executable (default: compiler-matched llvm-profdata) + LLVM_READELF llvm-readelf executable (default: compiler-matched llvm-readelf) + GEOS_SOURCE_DIR + coreComponents source directory + (default: PROJECT_ROOT/src/coreComponents) + +CI provenance (required): + GEOS_CI_CONTAINER_IMAGE + GEOS_CI_CONTAINER_IMAGE_ID + GEOS_CI_CONTAINER_IMAGE_DIGESTS + GEOS_CI_LLVM_PACKAGE_VERSIONS + Container label, immutable image ID, and JSON array of + immutable repository digests plus the exact runtime-installed + LLVM package revisions supplied by the CI launcher. + +Typical profile collection: + mkdir -p BUILD_DIR/profiles + LLVM_PROFILE_FILE='BUILD_DIR/profiles/%p-%m.profraw' \ + ctest --test-dir BUILD_DIR --parallel 12 --output-on-failure + +The script writes merged.profdata, llvm-summary.json, coverage-summary.json, +native.info, export logs, and branch-summary.txt. The JSON and .info files make +every numerator and denominator auditable with LLVM and LCOV tooling. +coverage-summary.json is the stable contract consumed by CI. +EOF +} + +if (( $# < 2 || $# > 3 )); then + usage >&2 + exit 2 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +project_root="$(dirname -- "${script_dir}")" +build_dir="$(cd -- "$1" && pwd -P)" +profile_dir="$(cd -- "$2" && pwd -P)" +output_arg="${3:-${build_dir}/llvm-source-coverage}" +mkdir -p -- "${output_arg}" +output_dir="$(cd -- "${output_arg}" && pwd -P)" +source_dir="${GEOS_SOURCE_DIR:-${project_root}/src/coreComponents}" +source_dir="$(cd -- "${source_dir}" && pwd -P)" +# A reused report directory must never mix fresh summaries with stale evidence +# from an earlier invocation that failed before exporting every format. +rm -f -- \ + "${output_dir}/branch-summary.txt" \ + "${output_dir}/branch-summary.txt.tmp" \ + "${output_dir}/coverage-objects.txt" \ + "${output_dir}/coverage-summary.json" \ + "${output_dir}/coverage-summary.json.tmp" \ + "${output_dir}/definition-export.log" \ + "${output_dir}/definition.info" \ + "${output_dir}/llvm-summary.json" \ + "${output_dir}/mapping-integrity.log" \ + "${output_dir}/merged.profdata" \ + "${output_dir}/native-export.log" \ + "${output_dir}/native.info" \ + "${output_dir}/profraw-inputs.txt" \ + "${output_dir}/summary-export.log" + +cache_file="${build_dir}/CMakeCache.txt" +if [[ ! -f "${cache_file}" ]]; then + echo "CMake cache not found: ${cache_file}" >&2 + exit 1 +fi +if ! grep -q '^GEOS_ENABLE_LLVM_SOURCE_COVERAGE:BOOL=ON$' "${cache_file}"; then + echo "Build does not have GEOS_ENABLE_LLVM_SOURCE_COVERAGE=ON" >&2 + exit 1 +fi +if ! grep -q '^ENABLE_COVERAGE:BOOL=OFF$' "${cache_file}"; then + echo "Build must have BLT ENABLE_COVERAGE=OFF" >&2 + exit 1 +fi + +c_compiler="$(sed -n 's/^CMAKE_C_COMPILER:[^=]*=//p' "${cache_file}" | head -n 1)" +cxx_compiler="$(sed -n 's/^CMAKE_CXX_COMPILER:[^=]*=//p' "${cache_file}" | head -n 1)" +for compiler in "${c_compiler}" "${cxx_compiler}"; do + if [[ ! -x "${compiler}" ]]; then + echo "Compiler from CMake cache is not executable: ${compiler}" >&2 + exit 1 + fi +done + +c_compiler_major="$("${c_compiler}" --version | sed -nE '1s/.*version[[:space:]]+([0-9]+).*/\1/p')" +cxx_compiler_major="$("${cxx_compiler}" --version | sed -nE '1s/.*version[[:space:]]+([0-9]+).*/\1/p')" +if [[ -z "${c_compiler_major}" || -z "${cxx_compiler_major}" || + "${c_compiler_major}" != "${cxx_compiler_major}" ]]; then + echo "Clang C/C++ compiler major versions are missing or inconsistent." >&2 + exit 1 +fi +compiler_major="${cxx_compiler_major}" + +resolve_llvm_tool() +{ + local environment_name="$1" + local base_name="$2" + local override="${!environment_name:-}" + + if [[ -n "${override}" ]]; then + command -v -- "${override}" + elif command -v -- "${base_name}-${compiler_major}" >/dev/null 2>&1; then + command -v -- "${base_name}-${compiler_major}" + else + command -v -- "${base_name}" + fi +} + +llvm_cov="$(resolve_llvm_tool LLVM_COV llvm-cov || true)" +llvm_profdata="$(resolve_llvm_tool LLVM_PROFDATA llvm-profdata || true)" +llvm_readelf="$(resolve_llvm_tool LLVM_READELF llvm-readelf || true)" +python="${PYTHON:-python3}" + +for tool in "${llvm_cov}" "${llvm_profdata}" "${llvm_readelf}" "${python}"; do + if ! command -v -- "${tool}" >/dev/null 2>&1; then + echo "Required tool not found: ${tool}" >&2 + exit 2 + fi +done + +for tool in "${llvm_cov}" "${llvm_profdata}" "${llvm_readelf}"; do + tool_major="$("${tool}" --version | sed -nE '1s/.*version[[:space:]]+([0-9]+).*/\1/p')" + if [[ "${tool_major}" != "${compiler_major}" ]]; then + echo "LLVM tool/compiler major-version mismatch: ${tool} is ${tool_major}, compiler is ${compiler_major}" >&2 + exit 2 + fi +done + +profile_list="${output_dir}/profraw-inputs.txt" +find "${profile_dir}" -type f -name '*.profraw' -print | LC_ALL=C sort > "${profile_list}" +if [[ ! -s "${profile_list}" ]]; then + echo "No .profraw files found below ${profile_dir}" >&2 + exit 1 +fi + +profdata="${output_dir}/merged.profdata" +"${llvm_profdata}" merge --input-files="${profile_list}" --output="${profdata}" + +# The denominator is the compiled CPU product: geosx and its component shared +# libraries. Test executables are profile producers, not shipped artifacts; +# including their private header instantiations would make the production +# denominator depend on the shape of the test suite. +declare -a candidate_objects=( "${build_dir}/bin/geosx" ) +if [[ ! -x "${candidate_objects[0]}" || ! -d "${build_dir}/lib" ]]; then + echo "geosx executable or build library directory not found below ${build_dir}" >&2 + exit 1 +fi +while IFS= read -r -d '' candidate; do + case "$(basename -- "${candidate}")" in + libgtest*|libtestingUtilities.*) continue ;; + esac + candidate_objects+=( "${candidate}" ) +done < <( + find "${build_dir}/lib" -maxdepth 1 -type f \ + \( -name '*.so' -o -name '*.so.*' -o -name '*.dylib' -o -name '*.dll' \) \ + -print0 | LC_ALL=C sort -z +) + +object_list="${output_dir}/coverage-objects.txt" +: > "${object_list}" +for candidate in "${candidate_objects[@]}"; do + if "${llvm_readelf}" --sections "${candidate}" 2>/dev/null | grep '__llvm_covmap' >/dev/null; then + printf '%s\n' "${candidate}" >> "${object_list}" + fi +done + +if [[ ! -s "${object_list}" ]]; then + echo "No objects with Clang coverage mappings found below ${build_dir}" >&2 + exit 1 +fi +if ! grep -Eq '/geosx(\.exe)?$' "${object_list}"; then + echo "Instrumented geosx executable not found below ${build_dir}" >&2 + exit 1 +fi +if ! grep -Eq '/libmainInterface\.(so(\..*)?|dylib|dll)$' "${object_list}"; then + echo "Instrumented mainInterface shared library not found below ${build_dir}" >&2 + exit 1 +fi + +mapfile -t coverage_objects < "${object_list}" +oldest_profile="" +while IFS= read -r profile; do + if [[ -z "${oldest_profile}" || "${oldest_profile}" -nt "${profile}" ]]; then + oldest_profile="${profile}" + fi +done < "${profile_list}" +for object in "${coverage_objects[@]}"; do + if [[ "${object}" -nt "${oldest_profile}" ]]; then + echo "Coverage object is newer than at least one input profile: ${object}" >&2 + exit 1 + fi +done + +primary_object="${coverage_objects[0]}" +declare -a object_args=() +for object in "${coverage_objects[@]:1}"; do + object_args+=( -object "${object}" ) +done + +production_exclude_regex='/(unitTests|unitTestUtilities|integrationTests|tests|examples|benchmarks|testingUtilities)/' +production_exclude_regex+='|/codingUtilities/UnitTestUtilities[.]hpp$' + +native_info="${output_dir}/native.info" +llvm_summary="${output_dir}/llvm-summary.json" +common_export_args=( + -instr-profile "${profdata}" + --sources "${source_dir}" + -ignore-filename-regex "${production_exclude_regex}" + -num-threads=1 +) + +integrity_log="${output_dir}/mapping-integrity.log" +if ! "${llvm_cov}" report "${primary_object}" "${object_args[@]}" \ + "${common_export_args[@]}" -dump > /dev/null 2> "${integrity_log}"; then + cat "${integrity_log}" >&2 + exit 1 +fi + +mismatch_count="$(awk \ + '/warning: [0-9]+ functions? have mismatched data/ { total += $2 } END { print total + 0 }' \ + "${integrity_log}")" +mismatch_details="$(grep -c '^hash-mismatch:' "${integrity_log}" || true)" +nonzero_mismatches="$(grep '^hash-mismatch:' "${integrity_log}" | grep -vc 'hash = 0x0$' || true)" +if (( mismatch_count != mismatch_details || nonzero_mismatches != 0 )); then + cat "${integrity_log}" >&2 + echo "Refusing to report coverage after a nonzero or unaudited function-hash mismatch." >&2 + exit 1 +fi + +integrity_warning_regex='hash mismatch|profile data.*out of date|object.*newer than profile' +integrity_warning_regex+='|no coverage data found|failed to load coverage' +if grep -Eiq "${integrity_warning_regex}" "${integrity_log}"; then + cat "${integrity_log}" >&2 + echo "Refusing to report coverage after an LLVM profile-integrity warning." >&2 + exit 1 +fi + +run_export() +{ + local output_file="$1" + local log_file="$2" + shift 2 + + if ! "${llvm_cov}" export "${primary_object}" "${object_args[@]}" \ + "${common_export_args[@]}" "$@" > "${output_file}" 2> "${log_file}"; then + cat "${log_file}" >&2 + return 1 + fi + + local export_mismatch_count + export_mismatch_count="$(awk \ + '/warning: [0-9]+ functions? have mismatched data/ { total += $2 } END { print total + 0 }' \ + "${log_file}")" + if (( export_mismatch_count != mismatch_count )); then + cat "${log_file}" >&2 + echo "LLVM export mismatch count differs from the audited mapping count." >&2 + return 1 + fi + + if grep -Eiq "${integrity_warning_regex}" "${log_file}"; then + cat "${log_file}" >&2 + echo "Refusing to report coverage after an LLVM profile-integrity warning." >&2 + return 1 + fi +} + +native_instantiation_args=() +llvm_cov_export_help="$("${llvm_cov}" export --help 2>&1)" +if grep -q -- '--unify-instantiations' <<< "${llvm_cov_export_help}"; then + native_instantiation_args=( -unify-instantiations=false ) +fi +run_export "${native_info}" "${output_dir}/native-export.log" \ + -format=lcov "${native_instantiation_args[@]}" +run_export "${llvm_summary}" "${output_dir}/summary-export.log" \ + -format=text -summary-only + +summarize_branches() +{ + # Count the BRDA outcome records directly. This deliberately does not trust + # BRF/BRH summaries because older llvm-cov versions can summarize template + # instances differently from the records emitted in the same LCOV file. + awk -F, ' + /^BRDA:/ { + total += 1 + if ($4 != "-" && ($4 + 0) > 0) { + covered += 1 + } + } + END { + percent = total ? 100.0 * covered / total : 0.0 + printf "%d %d %.6f\n", covered, total, percent + } + ' "$1" +} + +read -r native_covered native_total native_percent < <(summarize_branches "${native_info}") +read -r canonical_covered canonical_total canonical_percent < <( + "${python}" - "${llvm_summary}" <<'PY' +import json +import sys + +with open( sys.argv[1], encoding="utf-8" ) as stream: + document = json.load( stream ) + +branches = document["data"][0]["totals"]["branches"] +covered = int( branches["covered"] ) +total = int( branches["count"] ) +percent = 100.0 * covered / total if total else 0.0 +print( covered, total, f"{percent:.6f}" ) +PY +) +profile_count="$(wc -l < "${profile_list}")" +object_count="${#coverage_objects[@]}" +zero_hash_mappings="${mismatch_details}" + +if (( canonical_total == 0 || native_total == 0 )); then + echo "LLVM reported a zero production branch denominator." >&2 + exit 1 +fi + +summary="${output_dir}/branch-summary.txt" +summary_tmp="${summary}.tmp" +{ + printf 'scope: %s\n' "${source_dir}" + printf 'excluded directory regex: %s\n' "${production_exclude_regex}" + printf 'profiles: %d\n' "${profile_count}" + printf 'coverage objects: %d\n' "${object_count}" + printf 'zero-hash mappings without profile counters: %d\n' "${zero_hash_mappings}" + printf 'canonical LLVM branches: %d/%d (%.6f%%)\n' \ + "${canonical_covered}" "${canonical_total}" "${canonical_percent}" + printf 'native LCOV branch outcomes: %d/%d (%.6f%%)\n' \ + "${native_covered}" "${native_total}" "${native_percent}" +} | tee "${summary_tmp}" +mv -- "${summary_tmp}" "${summary}" + +coverage_summary="${output_dir}/coverage-summary.json" +coverage_summary_tmp="${coverage_summary}.tmp" +"${python}" - "${llvm_summary}" "${coverage_summary_tmp}" \ + "${project_root}" "${source_dir}" "${production_exclude_regex}" \ + "${compiler_major}" "${profile_count}" "${object_count}" \ + "${zero_hash_mappings}" \ + "${native_covered}" "${native_total}" \ + "${cache_file}" "${c_compiler}" "${cxx_compiler}" "${llvm_cov}" <<'PY' +import hashlib +import json +import os +import re +import subprocess +import sys + +( + llvm_summary_path, + output_path, + project_root, + source_dir, + excluded_regex, + compiler_major, + profile_count, + object_count, + zero_hash_mappings, + native_covered, + native_total, + cache_file, + c_compiler, + cxx_compiler, + llvm_cov, +) = sys.argv[1:] + +COVERAGE_CONTRACT_ID = "geos-llvm-source-coverage-v1" +METRIC_SEMANTICS = { + "canonical_metrics": "llvm-cov-summary-instantiation-groups-v1", + "native_branch_outcomes": "llvm-cov-lcov-emitted-brda-records-v1", + "source_selection": "llvm-cov-sources-and-ignore-regex-v1", +} +OBJECT_SELECTION = { + "contract_version": 1, + "primary_object": "bin/geosx", + "additional_object_directory": "lib", + "additional_object_globs": [ "*.dll", "*.dylib", "*.so", "*.so.*" ], + "excluded_library_basenames": [ "libgtest*", "libtestingUtilities.*" ], + "required_object_basenames": [ "geosx", "libmainInterface" ], + "coverage_mapping_section": "__llvm_covmap", + "test_executables": "excluded", +} + + +def command_output( description, command ): + try: + result = subprocess.run( + command, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except ( OSError, subprocess.CalledProcessError ) as error: + output = getattr( error, "stdout", "" ) or "" + raise ValueError( + f"cannot determine {description}: {output.strip() or error}" + ) from error + output = result.stdout.rstrip( "\r\n" ) + if not output: + raise ValueError( f"cannot determine {description}: empty output" ) + return output + + +def normalize_cmake_bool( value ): + value = value.strip() + upper = value.upper() + if upper in ( "", "0", "FALSE", "IGNORE", "N", "NO", "NOTFOUND", "OFF" ): + return False + if upper.endswith( "-NOTFOUND" ): + return False + return True + + +def normalized_build_config( path ): + entries = {} + cache_entry = re.compile( r"^([^#/:=]+):([^=]+)=(.*)$" ) + with open( path, encoding="utf-8" ) as stream: + for raw_line in stream: + match = cache_entry.match( raw_line.rstrip( "\r\n" ) ) + if match is None: + continue + name, entry_type, value = match.groups() + if entry_type == "INTERNAL": + continue + if name in entries: + raise ValueError( f"duplicate CMake cache entry: {name}" ) + entries[name] = ( entry_type, value.strip() ) + + if "CMAKE_BUILD_TYPE" not in entries: + raise ValueError( "CMake cache does not define CMAKE_BUILD_TYPE" ) + build_type = entries["CMAKE_BUILD_TYPE"][1] + build_type_suffix = re.sub( r"[^A-Za-z0-9]", "_", build_type ).upper() + active_flag_names = { + "CMAKE_C_FLAGS", + "CMAKE_CXX_FLAGS", + "CMAKE_EXE_LINKER_FLAGS", + "CMAKE_MODULE_LINKER_FLAGS", + "CMAKE_SHARED_LINKER_FLAGS", + f"CMAKE_C_FLAGS_{build_type_suffix}", + f"CMAKE_CXX_FLAGS_{build_type_suffix}", + f"CMAKE_EXE_LINKER_FLAGS_{build_type_suffix}", + f"CMAKE_MODULE_LINKER_FLAGS_{build_type_suffix}", + f"CMAKE_SHARED_LINKER_FLAGS_{build_type_suffix}", + } + fixed_names = { + "BLT_CXX_STD", + "BUILD_SHARED_LIBS", + "CMAKE_BUILD_TYPE", + "CMAKE_CUDA_ARCHITECTURES", + "CMAKE_C_EXTENSIONS", + "CMAKE_C_STANDARD", + "CMAKE_C_STANDARD_REQUIRED", + "CMAKE_CXX_EXTENSIONS", + "CMAKE_CXX_STANDARD", + "CMAKE_CXX_STANDARD_REQUIRED", + "CMAKE_INTERPROCEDURAL_OPTIMIZATION", + "CMAKE_POSITION_INDEPENDENT_CODE", + "CMAKE_UNITY_BUILD", + "GEOS_GLOBALINDEX_TYPE", + "GEOS_GLOBALINDEX_TYPE_FLAG", + "GEOS_LA_INTERFACE", + "GEOS_LA_INTERFACE_HYPRE", + "GEOS_LOCALINDEX_TYPE", + "GEOS_LOCALINDEX_TYPE_FLAG", + } + + config = {} + for name, ( entry_type, value ) in entries.items(): + relevant = ( + name in fixed_names + or name in active_flag_names + or name.startswith( "ENABLE_" ) + or name.startswith( "GEOS_BUILD_" ) + or name.startswith( "GEOS_ENABLE_" ) + or name.startswith( "LVARRAY_" ) + or name.startswith( "RAJA_ENABLE_" ) + ) + if not relevant: + continue + config[name] = normalize_cmake_bool( value ) if entry_type == "BOOL" else value + + required = { + "BLT_CXX_STD", + "BUILD_SHARED_LIBS", + "CMAKE_BUILD_TYPE", + "ENABLE_COVERAGE", + "ENABLE_CUDA", + "ENABLE_HIP", + "ENABLE_HYPRE", + "ENABLE_MPI", + "ENABLE_OPENMP", + "ENABLE_TRILINOS", + "GEOS_BUILD_SHARED_LIBS", + "GEOS_ENABLE_BOUNDS_CHECK", + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE", + "GEOS_GLOBALINDEX_TYPE", + "GEOS_LA_INTERFACE", + "GEOS_LOCALINDEX_TYPE", + "LVARRAY_BOUNDS_CHECK", + "RAJA_ENABLE_CUDA", + "RAJA_ENABLE_HIP", + "RAJA_ENABLE_OPENMP", + } + missing = sorted( required - config.keys() ) + if missing: + raise ValueError( + "CMake coverage configuration is incomplete: " + ", ".join( missing ) + ) + if config["ENABLE_COVERAGE"] is not False: + raise ValueError( "ENABLE_COVERAGE must be OFF in normalized build config" ) + if config["GEOS_ENABLE_LLVM_SOURCE_COVERAGE"] is not True: + raise ValueError( + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE must be ON in normalized build config" + ) + if config["BUILD_SHARED_LIBS"] is not True or \ + config["GEOS_BUILD_SHARED_LIBS"] is not True: + raise ValueError( "coverage object selection requires shared libraries" ) + return dict( sorted( config.items() ) ) + + +def container_provenance(): + environment_names = ( + "GEOS_CI_CONTAINER_IMAGE", + "GEOS_CI_CONTAINER_IMAGE_ID", + "GEOS_CI_CONTAINER_IMAGE_DIGESTS", + ) + missing = [ name for name in environment_names if not os.environ.get( name ) ] + if missing: + raise ValueError( + "missing CI container provenance: " + ", ".join( missing ) + ) + + image = os.environ["GEOS_CI_CONTAINER_IMAGE"] + image_id = os.environ["GEOS_CI_CONTAINER_IMAGE_ID"] + try: + image_digests = json.loads( os.environ["GEOS_CI_CONTAINER_IMAGE_DIGESTS"] ) + except json.JSONDecodeError as error: + raise ValueError( "GEOS_CI_CONTAINER_IMAGE_DIGESTS is not valid JSON" ) from error + if ( + not isinstance( image_digests, list ) + or not image_digests + or any( not isinstance( digest, str ) or not digest for digest in image_digests ) + ): + raise ValueError( + "GEOS_CI_CONTAINER_IMAGE_DIGESTS must be a nonempty JSON string array" + ) + image_digests = sorted( set( image_digests ) ) + if re.fullmatch( r"sha256:[0-9a-f]{64}", image_id ) is None: + raise ValueError( "GEOS_CI_CONTAINER_IMAGE_ID must be an immutable SHA256 ID" ) + for digest in image_digests: + if re.fullmatch( r"[^\s@]+@sha256:[0-9a-f]{64}", digest ) is None: + raise ValueError( "container repository digests must be immutable SHA256 digests" ) + if any( character in image for character in "\r\n\0" ): + raise ValueError( "GEOS_CI_CONTAINER_IMAGE contains a control character" ) + return { + "image": image, + "image_id": image_id, + "image_digests": image_digests, + } + + +git_command = [ + "git", + "-c", + f"safe.directory={project_root}", + "-C", + project_root, + "rev-parse", + "--verify", +] +commit_sha = command_output( "Git HEAD commit", git_command + [ "HEAD^{commit}" ] ) +tree_sha = command_output( "Git HEAD tree", git_command + [ "HEAD^{tree}" ] ) +git_object_id = re.compile( r"(?:[0-9a-f]{40}|[0-9a-f]{64})" ) +if git_object_id.fullmatch( commit_sha ) is None or \ + git_object_id.fullmatch( tree_sha ) is None: + raise ValueError( "Git commit/tree provenance is not a canonical object ID" ) + +c_compiler_version = command_output( "C compiler version", [ c_compiler, "--version" ] ) +cxx_compiler_version = command_output( + "C++ compiler version", [ cxx_compiler, "--version" ] +) +llvm_cov_version = command_output( "llvm-cov version", [ llvm_cov, "--version" ] ) +c_compiler_target = command_output( "C compiler target", [ c_compiler, "-dumpmachine" ] ) +cxx_compiler_target = command_output( + "C++ compiler target", [ cxx_compiler, "-dumpmachine" ] +) +if c_compiler_target != cxx_compiler_target: + raise ValueError( "C and C++ compiler targets differ" ) + +container = container_provenance() +toolchain = { + "c_compiler_version": c_compiler_version, + "cxx_compiler_version": cxx_compiler_version, + "llvm_cov_version": llvm_cov_version, + "compiler_target": cxx_compiler_target, +} +package_versions = os.environ.get( "GEOS_CI_LLVM_PACKAGE_VERSIONS", "" ).splitlines() +if ( + not package_versions + or package_versions != sorted( set( package_versions ) ) + or any( re.fullmatch( r"[A-Za-z0-9.+-]+=[^\s=]+", entry ) is None + for entry in package_versions ) +): + raise ValueError( + "GEOS_CI_LLVM_PACKAGE_VERSIONS must be a sorted package=version list" + ) +toolchain["llvm_package_versions"] = package_versions +build_config = normalized_build_config( cache_file ) + +with open( llvm_summary_path, encoding="utf-8" ) as stream: + llvm_document = json.load( stream ) + +totals = llvm_document["data"][0]["totals"] + +def metric( covered, total ): + covered = int( covered ) + total = int( total ) + if covered < 0 or total < 0 or covered > total: + raise ValueError( "LLVM emitted invalid coverage counts" ) + return { + "covered": covered, + "total": total, + "not_covered": total - covered, + "percent": round( 100.0 * covered / total if total else 0.0, 6 ), + } + +canonical = {} +for name in ( "regions", "functions", "lines", "branches" ): + canonical[name] = metric( totals[name]["covered"], totals[name]["count"] ) + +source_root = os.path.realpath( source_dir ) +branch_gaps = [] +per_file_metrics = [] +file_aggregates = { + name: { "covered": 0, "total": 0 } + for name in ( "regions", "functions", "lines", "branches" ) +} +seen_paths = set() +for source in llvm_document["data"][0]["files"]: + raw_filename = source["filename"] + if not os.path.isabs( raw_filename ): + raw_filename = os.path.join( project_root, raw_filename ) + filename = os.path.realpath( raw_filename ) + try: + in_scope = os.path.commonpath( ( source_root, filename ) ) == source_root + except ValueError: + in_scope = False + if not in_scope: + continue + relative_path = os.path.relpath( filename, project_root ).replace( os.sep, "/" ) + if relative_path in seen_paths: + raise ValueError( f"duplicate LLVM per-file coverage path: {relative_path}" ) + seen_paths.add( relative_path ) + file_metrics = {} + for name in ( "regions", "functions", "lines", "branches" ): + llvm_metric = source["summary"][name] + file_metrics[name] = metric( llvm_metric["covered"], llvm_metric["count"] ) + file_aggregates[name]["covered"] += file_metrics[name]["covered"] + file_aggregates[name]["total"] += file_metrics[name]["total"] + per_file_metrics.append( { "path": relative_path, "metrics": file_metrics } ) + + branch_metric = file_metrics["branches"] + if branch_metric["not_covered"] == 0: + continue + branch_gaps.append( + { + "path": relative_path, + **branch_metric, + } + ) +per_file_metrics.sort( key=lambda source: source["path"] ) +branch_gaps.sort( key=lambda gap: ( -gap["not_covered"], gap["path"] ) ) +for name, aggregate in file_aggregates.items(): + if ( aggregate["covered"], aggregate["total"] ) != ( + canonical[name]["covered"], canonical[name]["total"] + ): + raise ValueError( f"per-file {name} counts do not match canonical totals" ) + +scope = os.path.relpath( source_dir, project_root ).replace( os.sep, "/" ) +contract_payload = { + "summary_schema_version": 3, + "contract_id": COVERAGE_CONTRACT_ID, + "scope": scope, + "excluded_regex": excluded_regex, + "metric_semantics": METRIC_SEMANTICS, + "container": { + "image_id": container["image_id"], + "image_digests": container["image_digests"], + }, + "toolchain": toolchain, + "build_config": build_config, + "object_selection": OBJECT_SELECTION, +} +contract_bytes = json.dumps( + contract_payload, + allow_nan=False, + ensure_ascii=True, + separators=( ",", ":" ), + sort_keys=True, +).encode( "utf-8" ) +contract_fingerprint = hashlib.sha256( contract_bytes ).hexdigest() + +document = { + "schema_version": 3, + "scope": scope, + "excluded_regex": excluded_regex, + "measurement": { + "commit_sha": commit_sha, + "tree_sha": tree_sha, + "contract_id": COVERAGE_CONTRACT_ID, + "contract_fingerprint": contract_fingerprint, + "container": container, + "toolchain": toolchain, + "build_config": build_config, + "metric_semantics": METRIC_SEMANTICS, + "object_selection": OBJECT_SELECTION, + }, + "tool": { + "name": "llvm-cov", + "major": int( compiler_major ), + }, + "inputs": { + "profiles": int( profile_count ), + "coverage_objects": int( object_count ), + "zero_hash_mappings": int( zero_hash_mappings ), + }, + "metrics": canonical, + "per_file_metrics": per_file_metrics, + "top_branch_gaps": branch_gaps[:5], + "supplemental": { + "native_branch_outcomes": metric( native_covered, native_total ), + }, +} + +with open( output_path, "w", encoding="utf-8" ) as stream: + json.dump( document, stream, indent=2, sort_keys=True ) + stream.write( "\n" ) +PY +mv -- "${coverage_summary_tmp}" "${coverage_summary}" diff --git a/scripts/render_coverage_report_html.py b/scripts/render_coverage_report_html.py new file mode 100644 index 00000000000..3d418ca1f6a --- /dev/null +++ b/scripts/render_coverage_report_html.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +"""Render the LLVM source-coverage artifact as one self-contained HTML page.""" + +from __future__ import annotations + +import argparse +import html +import json +import os +import pathlib +import re +import sys +import tempfile + + +SCRIPT_DIRECTORY = pathlib.Path( __file__ ).resolve().parent +if str( SCRIPT_DIRECTORY ) not in sys.path: + sys.path.insert( 0, str( SCRIPT_DIRECTORY ) ) +import check_coverage_thresholds as coverage_contract + + +METRIC_NAMES = ( "lines", "functions", "branches", "regions" ) +METRIC_LABELS = { + "branches": "Canonical branches", + "functions": "Functions", + "lines": "Lines", + "regions": "Regions", +} +PATCH_METRIC_LABELS = { + "executable_lines": "Executable changed lines", + "function_start_sites": "Changed function start sites", + "native_branch_outcomes": "Native changed branch outcomes", +} +DETAIL_FILES = ( + ( "CTest output", "ctest.log" ), + ( "LLVM mapping-integrity log", "mapping-integrity.log" ), + ( "Native export log", "native-export.log" ), + ( "Summary export log", "summary-export.log" ), + ( "Branch summary", "branch-summary.txt" ), + ( "Coverage objects", "coverage-objects.txt" ), + ( "Toolchain", "toolchain.txt" ), + ( "CMake configuration", "CMakeCache.txt" ), + ( "Coverage status source", "coverage-status.md" ), + ( "Coverage policy source", "coverage-summary.md" ), + ( "Pull-request coverage source", "pr-coverage.md" ), + ( "Baseline status", "coverage-baseline-status.json" ), + ( "Coverage summary JSON", "coverage-summary.json" ), + ( "Pull-request coverage JSON", "pr-coverage.json" ), + ( "LLVM summary JSON", "llvm-summary.json" ), +) + + +def escape( value: object ) -> str: + return html.escape( str( value ), quote=True ) + + +def read_text( path: pathlib.Path ) -> str | None: + if not path.is_file() or path.is_symlink(): + return None + try: + return path.read_text( encoding="utf-8", errors="replace" ) + except OSError: + return None + + +def read_json( path: pathlib.Path ) -> dict | None: + text = read_text( path ) + if text is None: + return None + try: + document = json.loads( text ) + except json.JSONDecodeError: + return None + return document if isinstance( document, dict ) else None + + +def write_atomic( path: pathlib.Path, contents: str ) -> None: + """Replace a report even when a container created the old file as root.""" + path.parent.mkdir( parents=True, exist_ok=True ) + if path.is_symlink(): + raise ValueError( f"refusing to replace symbolic link: {path}" ) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary_path = pathlib.Path( temporary_name ) + try: + os.fchmod( descriptor, 0o644 ) + with os.fdopen( descriptor, "w", encoding="utf-8" ) as stream: + stream.write( contents ) + stream.flush() + os.fsync( stream.fileno() ) + os.replace( temporary_path, path ) + temporary_path = None + except BaseException: + temporary_path.unlink( missing_ok=True ) + raise + + +def validated_summary( document: dict | None ) -> dict | None: + if document is None: + return None + try: + return coverage_contract.validate_summary( document ) + except ValueError: + return document + + +def validated_policy( document: dict | None, scope: str | None ) -> dict: + if document is None or scope is None: + return {} + try: + return coverage_contract.validate_policy( document, scope ) + except ValueError: + raw_thresholds = document.get( "minimum_basis_points", {} ) + if not isinstance( raw_thresholds, dict ): + return {} + return { + name: value + for name, value in raw_thresholds.items() + if name in METRIC_NAMES and isinstance( value, int ) + } + + +def number( value: object ) -> str: + if isinstance( value, int ) and not isinstance( value, bool ): + return f"{value:,}" + return escape( value ) + + +def metric_percent( metric: dict | None ) -> str: + if not isinstance( metric, dict ): + return "N/A" + total = metric.get( "total", 0 ) + covered = metric.get( "covered", 0 ) + if not isinstance( total, int ) or total == 0: + return "N/A" + return f"{100.0 * covered / total:.2f}%" + + +def metric_counts( metric: dict | None ) -> str: + if not isinstance( metric, dict ): + return "N/A" + return f"{number( metric.get( 'covered', 0 ) )}/{number( metric.get( 'total', 0 ) )}" + + +def policy_result( metric: dict | None, threshold: int | None ) -> tuple[str, str]: + if not isinstance( metric, dict ) or threshold is None: + return "info", "Measured" + total = metric.get( "total", 0 ) + covered = metric.get( "covered", 0 ) + if not isinstance( total, int ) or not isinstance( covered, int ) or total == 0: + return "info", "N/A" + passed = covered * 10000 >= threshold * total + return ( "pass", "Pass" ) if passed else ( "fail", "Fail" ) + + +def badge( state: str, label: str ) -> str: + icons = { "pass": "✓", "fail": "✕", "warn": "!", "info": "i" } + return ( + f'' + f'{icons.get( state, "i" )}' + f"{escape( label )}" + ) + + +def table( headers: list[str], rows: list[list[str]], class_name="" ) -> str: + header_html = "".join( f"{header}" for header in headers ) + body = [] + for row in rows: + body.append( "" + "".join( f"{cell}" for cell in row ) + "" ) + return ( + f'
' + f"{header_html}{''.join( body )}" + "
" + ) + + +def card( title: str, result: str, detail: str, state: str ) -> str: + return ( + f'
' + f"
{escape( title )}
" + f'
{badge( state, result )}
' + f'
{detail}
' + "
" + ) + + +def markdown_table_value( document: str, label: str ) -> tuple[str, str] | None: + for line in document.splitlines(): + cells = [ cell.strip() for cell in line.strip().strip( "|" ).split( "|" ) ] + if len( cells ) >= 3 and cells[0] == label: + return cells[1], cells[2] + return None + + +def status_details( status_markdown: str | None ) -> dict: + status = "info" + result = "Unavailable" + if status_markdown: + if re.search( r"^### ✅ LLVM source coverage passed", status_markdown, re.MULTILINE ): + status, result = "pass", "Passed" + elif re.search( r"^### ❌ LLVM source coverage failed", status_markdown, re.MULTILINE ): + status, result = "fail", "Failed" + tests = markdown_table_value( status_markdown or "", "Coverage smoke suite" ) + if tests is None: + tests = markdown_table_value( status_markdown or "", "Coverage smoke CTest" ) + configuration = {} + for label in ( "Runner", "Container", "Immutable image ID (short)", "Toolchain", "Build", "Test scheduling" ): + value = markdown_table_value( status_markdown or "", label ) + if value is not None: + configuration[label] = value[1].strip( "`" ) + return { + "state": status, + "result": result, + "tests": tests, + "configuration": configuration, + } + + +def metric_rows( summary: dict | None, thresholds: dict ) -> list[list[str]]: + if summary is None: + return [] + rows = [] + metrics = summary.get( "metrics", {} ) + for name in METRIC_NAMES: + metric = metrics.get( name ) + threshold = thresholds.get( name ) + state, result = policy_result( metric, threshold ) + requirement = "—" if threshold is None else f"≥ {threshold / 100:.2f}%" + percent = metric_percent( metric ) + width = 0.0 + if isinstance( metric, dict ) and isinstance( metric.get( "percent" ), ( int, float ) ): + width = max( 0.0, min( 100.0, float( metric["percent"] ) ) ) + progress = ( + f'
' + ) + rows.append( + [ + escape( METRIC_LABELS[name] ), + f"{escape( percent )}{progress}", + number( metric.get( "covered", 0 ) ) if isinstance( metric, dict ) else "N/A", + number( metric.get( "not_covered", 0 ) ) if isinstance( metric, dict ) else "N/A", + number( metric.get( "total", 0 ) ) if isinstance( metric, dict ) else "N/A", + escape( requirement ), + badge( state, result ), + ] + ) + return rows + + +def policy_card( summary: dict | None, thresholds: dict ) -> tuple[str, str, str]: + if summary is None: + return "info", "Unavailable", "No validated coverage summary was produced." + results = [ policy_result( summary.get( "metrics", {} ).get( name ), threshold )[0] + for name, threshold in thresholds.items() ] + if not results: + return "info", "Measured", "No repository thresholds are configured." + if "fail" in results: + return "fail", "Failed", "At least one enforced coverage floor is below the required value." + if "info" in results: + return "info", "Unavailable", "One or more configured coverage metrics are unavailable." + return "pass", "Passed", "All configured lines, functions, and branch thresholds are satisfied." + + +def baseline_card( report: dict | None ) -> tuple[str, str, str]: + if report is None: + return "info", "Not applicable", "This artifact was not produced for a pull request." + aggregate = report.get( "aggregate_comparison", {} ) + if not aggregate.get( "comparable", False ): + return "info", "Unavailable", aggregate.get( + "reason", "No exact-base coverage summary was available." + ) + deltas = [ + comparison.get( "percentage_point_delta", 0.0 ) + for comparison in aggregate.get( "metrics", {} ).values() + ] + if deltas and all( delta >= 0 for delta in deltas ): + return "pass", "Maintained / improved", "Every comparable repository metric stayed level or increased." + if deltas and all( delta <= 0 for delta in deltas ): + return "warn", "Decreased", "At least one comparable repository metric decreased." + if not deltas: + return "info", "Unavailable", "No comparable repository metrics were provided." + return "warn", "Mixed", "Some comparable repository metrics increased while others decreased." + + +def changed_lines_card( report: dict | None ) -> tuple[str, str, str]: + if report is None: + return "info", "Not applicable", "This artifact was not produced for a pull request." + metric = report.get( "patch", {} ).get( "metrics", {} ).get( "executable_lines", {} ) + total = metric.get( "total", 0 ) + missed = metric.get( "not_covered", 0 ) + if total == 0: + return "info", "No executable changes", "The production diff contains no executable changed lines." + if missed: + return "warn", "Review needed", f"{number( metric.get( 'covered', 0 ) )}/{number( total )} executable changed lines are covered." + return "pass", "Covered", f"All {number( total )} executable changed lines are covered." + + +def coverage_section( summary: dict | None, thresholds: dict ) -> str: + if summary is None: + return '

Repository coverage

Coverage metrics are unavailable.

' + rows = metric_rows( summary, thresholds ) + scope = escape( summary.get( "scope", "unknown" ) ) + reporter = f"{summary.get( 'tool', {} ).get( 'name', 'unknown' )} {summary.get( 'tool', {} ).get( 'major', '?' )}" + result = ( + f'

Repository coverage

' + f'

Production scope {scope} · Reporter {escape( reporter )}. ' + "Thresholds are evaluated using exact covered and total counts.

" + + table( + [ "Metric", "Coverage", "Covered", "Missed", "Total", "Requirement", "Result" ], + rows, + "metrics", + ) + ) + gaps = summary.get( "top_branch_gaps", [] ) + if gaps: + gap_rows = [] + scope_prefix = summary.get( "scope", "" ).rstrip( "/" ) + "/" + for gap in gaps: + path = gap.get( "path", "" ) + if path.startswith( scope_prefix ): + path = path[len( scope_prefix ):] + gap_rows.append( + [ + f"{escape( path )}", + number( gap.get( "covered", 0 ) ), + number( gap.get( "not_covered", 0 ) ), + number( gap.get( "total", 0 ) ), + escape( metric_percent( gap ) ), + ] + ) + result += ( + "

Largest branch gaps

" + + table( [ "Source file", "Covered", "Missed", "Total", "Coverage" ], gap_rows ) + + '

Ranked by missed canonical branch outcomes within the production scope.

' + ) + supplemental = summary.get( "supplemental", {} ).get( "native_branch_outcomes" ) + inputs = summary.get( "inputs", {} ) + collection_rows = [ + [ "Profile files merged", number( inputs.get( "profiles", "N/A" ) ) ], + [ "Instrumented product objects", number( inputs.get( "coverage_objects", "N/A" ) ) ], + [ "Audited zero-hash mappings", number( inputs.get( "zero_hash_mappings", "N/A" ) ) ], + ] + result += ( + "

Coverage collection

" + + table( [ "Input", "Value" ], collection_rows ) + + '

Zero-hash mappings have no profile counters and are audited during report generation; any nonzero or unaudited mismatch fails the job.

' + ) + if supplemental is not None: + result += ( + '
Diagnostic only: native branch outcomes ' + f"{escape( metric_counts( supplemental ) )} ({escape( metric_percent( supplemental ))}). " + "They are instantiation-weighted and are not the canonical branch gate.
" + ) + return result + "
" + + +def comparison_table( report: dict ) -> str: + aggregate = report.get( "aggregate_comparison", {} ) + if not aggregate.get( "comparable", False ): + reason = escape( aggregate.get( "reason", "No exact-base coverage summary was available." ) ) + return table( [ "Comparison", "Result", "Reason" ], [ [ "Exact base → candidate", badge( "info", "Unavailable" ), reason ] ] ) + rows = [] + for name in METRIC_NAMES: + comparison = aggregate.get( "metrics", {} ).get( name ) + if comparison is None: + continue + delta = comparison.get( "percentage_point_delta", 0.0 ) + state = "pass" if delta >= 0 else "warn" + rows.append( + [ + escape( METRIC_LABELS[name] ), + escape( metric_counts( comparison.get( "baseline" ) ) + " (" + metric_percent( comparison.get( "baseline" ) ) + ")" ), + escape( metric_counts( comparison.get( "candidate" ) ) + " (" + metric_percent( comparison.get( "candidate" ) ) + ")" ), + f'{escape( f"{delta:+.2f} pp" )}', + escape( f"{comparison.get( 'covered_delta', 0):+,}" ), + escape( f"{comparison.get( 'not_covered_delta', 0):+,}" ), + escape( f"{comparison.get( 'total_delta', 0):+,}" ), + ] + ) + return table( + [ "Metric", "Exact base", "Candidate", "Coverage change", "Covered Δ", "Missed Δ", "Total Δ" ], + rows, + ) + + +def pull_request_section( report: dict | None ) -> str: + if report is None: + return '

Pull-request coverage

No pull-request coverage report was produced.

' + aggregate = report.get( "aggregate_comparison", {} ) + patch = report.get( "patch", {} ) + base = escape( str( report.get( "base_sha", "unknown" ) )[:12] ) + head = escape( str( report.get( "head_sha", "unknown" ) )[:12] ) + scope = escape( report.get( "scope", "unknown" ) ) + result = ( + f'

Pull-request coverage

' + f'

Effective source diff {base}{head} · Production scope {scope}.

' + "

Project coverage change

" + + comparison_table( report ) + ) + if aggregate.get( "comparable", False ): + result += '

Comparison is exact because commit, tree, and measurement-contract provenance match.

' + else: + result += '

Baseline comparison is advisory and is unavailable when no exact-base artifact is present.

' + + patch_metrics = patch.get( "metrics", {} ) + patch_rows = [] + interpretations = { + "executable_lines": "Exact LLVM line mapping on added/modified head lines", + "function_start_sites": "Advisory; C++ instantiations may share a source definition", + "native_branch_outcomes": "Advisory and instantiation-weighted; not the canonical branch gate", + } + for name, label in PATCH_METRIC_LABELS.items(): + metric = patch_metrics.get( name, {} ) + state = "pass" if metric.get( "total", 0 ) == 0 or metric.get( "not_covered", 0 ) == 0 else "warn" + patch_rows.append( + [ + escape( label ), + number( metric.get( "covered", 0 ) ), + number( metric.get( "not_covered", 0 ) ), + number( metric.get( "total", 0 ) ), + escape( metric_percent( metric ) ), + badge( state, "Covered" if state == "pass" else "Review" ), + escape( interpretations[name] ), + ] + ) + result += "

Changed-code coverage

" + table( + [ "Signal", "Covered", "Missed", "Total", "Coverage", "Review", "Interpretation" ], + patch_rows, + ) + result += ( + f'

Git identified {number( patch.get( "changed_lines", 0 ) )} ' + f"added/modified head lines across {number( patch.get( 'changed_files', 0 ) )} production files. " + f"{number( patch.get( 'non_instrumented_changed_lines', 0 ) )} changed lines have no LLVM line mapping." + "

" + ) + + impacted_files = patch.get( "top_impacted_files", [] ) + if impacted_files: + file_rows = [] + for source in impacted_files: + metrics = source.get( "metrics", {} ) + file_rows.append( + [ + f'{escape( source.get( "path", "" ) )}', + escape( source.get( "status", "" ) ), + number( source.get( "changed_lines", 0 ) ), + escape( metric_counts( metrics.get( "executable_lines" ) ) ), + escape( metric_counts( metrics.get( "function_start_sites" ) ) ), + escape( metric_counts( metrics.get( "native_branch_outcomes" ) ) ), + ] + ) + result += "

Changed files with the largest coverage gaps

" + table( + [ "Source file", "Change", "Changed lines", "Executable lines", "Function starts", "Native branches" ], + file_rows, + ) + + locations = patch.get( "top_impacted_locations", [] ) + result += "

Highest-impact changed locations

" + if locations: + location_rows = [] + for location in locations: + gaps = [] + if location.get( "executable_line" ) and not location.get( "line_covered" ): + gaps.append( "executable line not hit" ) + missed_branches = location.get( "native_branch_outcomes", {} ).get( "not_covered", 0 ) + if missed_branches: + gaps.append( f"{number( missed_branches )} native branch outcome(s) missed" ) + if location.get( "function_start_covered" ) is False: + gaps.append( "function start not entered" ) + location_rows.append( + [ + f'{escape( location.get( "path", "" ) )}:{number( location.get( "line", "" ) )}', + escape( "; ".join( gaps ) ), + escape( location.get( "hint", "" ) ), + ] + ) + result += table( [ "Location", "Coverage gap", "Suggested test" ], location_rows ) + else: + result += '

No uncovered instrumented changed locations were found.

' + return result + ( + '

Changed-line coverage is exact. Function-start and native branch details are diagnostics because LLVM 20 retains C++ template and inline-function instantiations.

' + ) + + +def details_section( artifact_dir: pathlib.Path ) -> str: + sections = [] + for label, filename in DETAIL_FILES: + path = artifact_dir / filename + text = read_text( path ) + if text is None: + continue + if filename.endswith( ".json" ): + document = read_json( path ) + if document is not None: + text = json.dumps( document, indent=2, sort_keys=True, ensure_ascii=False ) + "\n" + sections.append( + f'
{escape( label )} {escape( filename )} ' + f'{number( len( text.encode( "utf-8" ) ) )} bytes' + f'
{escape( text )}
' + ) + if not sections: + return '

Diagnostics and raw data

No diagnostic files were produced.

' + return '

Diagnostics and raw data

All intermediate reports are retained below in collapsible sections, so this single HTML file remains the complete downloadable artifact.

' + "".join( sections ) + "
" + + +def render_html( artifact_dir: pathlib.Path ) -> str: + status_markdown = read_text( artifact_dir / "coverage-status.md" ) + status = status_details( status_markdown ) + summary = validated_summary( read_json( artifact_dir / "coverage-summary.json" ) ) + policy_document = read_json( pathlib.Path( __file__ ).parents[1] / ".github" / "coverage-thresholds.json" ) + thresholds = validated_policy( policy_document, summary.get( "scope" ) if summary else None ) + report = read_json( artifact_dir / "pr-coverage.json" ) + policy_state, policy_result_text, policy_detail = policy_card( summary, thresholds ) + baseline_state, baseline_result, baseline_detail = baseline_card( report ) + changed_state, changed_result, changed_detail = changed_lines_card( report ) + test_result, test_detail = status.get( "tests" ) or ( "Unavailable", "No CTest summary was recorded." ) + overall_state = status["state"] + overall_result = status["result"] + if overall_state == "info": + overall_state = "fail" if policy_state == "fail" else policy_state + overall_result = "Failed" if overall_state == "fail" else "Available" + summary_metrics = summary or {} + scope = escape( summary_metrics.get( "scope", "src/coreComponents" ) ) + measurement = summary_metrics.get( "measurement", {} ) + commit = escape( str( measurement.get( "commit_sha", "unknown" ) )[:12] ) + configuration = status.get( "configuration", {} ) + config_rows = [ [ escape( label ), escape( value ) ] for label, value in configuration.items() ] + if not config_rows and measurement: + toolchain = measurement.get( "toolchain", {} ) + config_rows = [ + [ "Compiler target", escape( toolchain.get( "compiler_target", "unknown" ) ) ], + [ "LLVM", escape( summary_metrics.get( "tool", {} ).get( "major", "unknown" ) ) ], + ] + nav = ''.join( + f'{escape( label )}' + for anchor, label in ( + ( "coverage", "Coverage" ), + ( "pull-request", "Pull request" ), + ( "details", "Diagnostics" ), + ) + ) + return f""" + + + + +GEOS LLVM source coverage + + + +
+
+

GEOS CI · LLVM source coverage

+

{badge( overall_state, overall_result )} Coverage report

+
Production scope {scope} · measured commit {commit}
+
+ +
+{card( "Coverage smoke tests", test_result, escape( test_detail ), "pass" if "pass" in test_result.lower() else "fail" if "fail" in test_result.lower() else "info" )} +{card( "Enforced policy", policy_result_text, escape( policy_detail ), policy_state )} +{card( "Baseline comparison", baseline_result, escape( baseline_detail ), baseline_state )} +{card( "Changed production lines", changed_result, escape( changed_detail ), changed_state )} +
+{coverage_section( summary, thresholds )} +{pull_request_section( report )} +

Run configuration

{table( [ "Item", "Value" ], config_rows, "config" ) if config_rows else '

Run configuration is unavailable.

'}
+{details_section( artifact_dir )} +
+ + +""" + + +def main() -> int: + parser = argparse.ArgumentParser( description=__doc__ ) + parser.add_argument( "--artifact-dir", required=True, type=pathlib.Path ) + parser.add_argument( "--output", required=True, type=pathlib.Path ) + arguments = parser.parse_args() + try: + write_atomic( arguments.output, render_html( arguments.artifact_dir ) ) + except ( OSError, ValueError ) as error: + print( f"Coverage HTML report error: {error}", file=sys.stderr ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit( main() ) diff --git a/scripts/render_coverage_review_summary.py b/scripts/render_coverage_review_summary.py new file mode 100644 index 00000000000..c53fbbd4f24 --- /dev/null +++ b/scripts/render_coverage_review_summary.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +"""Render the concise reviewer-facing portion of the LLVM coverage summary.""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys + + +SCRIPT_DIRECTORY = pathlib.Path( __file__ ).resolve().parent +if str( SCRIPT_DIRECTORY ) not in sys.path: + sys.path.insert( 0, str( SCRIPT_DIRECTORY ) ) +import check_coverage_thresholds as coverage_contract + + +DISPLAY_NAMES = { + "regions": "Regions", + "functions": "Functions", + "lines": "Lines", + "branches": "Canonical branches", +} +CONSOLE_METRICS = ( "lines", "functions", "branches" ) + + +def render_console_summary( summary: dict, thresholds: dict ) -> str: + """Render a plain-text table for the GitHub Actions log.""" + rows = [] + all_passed = True + for name in CONSOLE_METRICS: + metric = summary["metrics"][name] + threshold = thresholds[name] + passed = metric["covered"] * 10000 >= threshold * metric["total"] + all_passed = all_passed and passed + rows.append( + ( + DISPLAY_NAMES[name], + f"{metric['percent']:.2f}%", + f"{metric['covered']:,} / {metric['total']:,}", + f"{metric['not_covered']:,}", + f">= {threshold / 100:.2f}%", + "✅ Pass" if passed else "❌ Fail", + ) + ) + + headers = ( "Metric", "Coverage", "Covered / total", "Missed", "Requirement", "Result" ) + widths = [ + max( len( headers[index] ), *( len( row[index] ) for row in rows ) ) + for index in range( len( headers ) ) + ] + + def border() -> str: + return "+" + "+".join( "-" * ( width + 2 ) for width in widths ) + "+" + + def row( values ) -> str: + return "|" + "|".join( + f" {value:<{width}} " + for value, width in zip( values, widths ) + ) + "|" + + status = "✅ PASS" if all_passed else "❌ FAIL" + output = [ + "", + "LLVM source coverage policy", + f"Overall result: {status}", + border(), + row( headers ), + border(), + *( row( values ) for values in rows ), + border(), + "", + ] + return "\n".join( output ) + + +def read_json( path: pathlib.Path, description: str ) -> dict: + try: + with path.open( encoding="utf-8" ) as stream: + value = json.load( stream ) + except ( OSError, UnicodeError, json.JSONDecodeError ) as error: + raise ValueError( f"cannot read {description} {path}: {error}" ) from error + if not isinstance( value, dict ): + raise ValueError( f"{description} must contain an object" ) + return value + + +def metric_text( metric: dict ) -> str: + return ( + f"{metric['percent']:.2f}% " + f"({metric['covered']:,}/{metric['total']:,})" + ) + + +def policy_row( summary: dict, thresholds: dict ) -> str: + details = [] + all_passed = True + for name in coverage_contract.DISPLAY_METRICS: + if name not in thresholds: + continue + metric = summary["metrics"][name] + threshold = thresholds[name] + passed = metric["covered"] * 10000 >= threshold * metric["total"] + all_passed = all_passed and passed + result = "✅" if passed else "❌" + details.append( + f"{result} {DISPLAY_NAMES[name]} {metric_text( metric )} " + f"≥ {threshold / 100:.2f}%" + ) + status = "✅ Pass" if all_passed else "❌ Fail" + return ( + f"| Enforced coverage policy | {status} | " + f"{'
'.join( details )} |" + ) + + +def comparison_row( report: dict | None ) -> str: + if report is None: + return ( + "| Baseline comparison | ⏭️ Not applicable | " + "This is not a pull-request coverage run. |" + ) + + aggregate = report.get( "aggregate_comparison", {} ) + if not aggregate.get( "comparable", False ): + reason = aggregate.get( "reason", "No exact-base coverage summary was provided." ) + if reason == "No exact-base coverage summary was provided.": + reason = "No exact-base baseline artifact was available." + return f"| Baseline comparison | ℹ️ Unavailable | {reason} |" + + deltas = [] + for name in coverage_contract.DISPLAY_METRICS: + comparison = aggregate["metrics"].get( name ) + if comparison is not None: + deltas.append( comparison["percentage_point_delta"] ) + if all( delta >= 0 for delta in deltas ): + result = "✅ Maintained/improved" + elif all( delta <= 0 for delta in deltas ): + result = "⚠️ Decreased" + else: + result = "⚠️ Mixed" + details = "; ".join( + f"{DISPLAY_NAMES[name]} {aggregate['metrics'][name]['percentage_point_delta']:+.2f} pp" + for name in coverage_contract.DISPLAY_METRICS + if name in aggregate["metrics"] + ) + return f"| Baseline comparison | {result} | {details} |" + + +def changed_lines_row( report: dict | None ) -> str: + if report is None: + return ( + "| Changed production lines | ⏭️ Not applicable | " + "This is not a pull-request coverage run. |" + ) + + metric = report["patch"]["metrics"]["executable_lines"] + if metric["total"] == 0: + return ( + "| Changed production lines | ℹ️ None | " + "The production diff contains no executable changed lines. |" + ) + + locations = [] + for location in report["patch"].get( "top_impacted_locations", [] ): + if location.get( "executable_line" ) and not location.get( "line_covered" ): + locations.append( f"{location['path']}:{location['line']}" ) + if len( locations ) == 3: + break + details = ( + f"{metric['covered']:,}/{metric['total']:,} executable changed lines covered" + ) + if locations: + details += "; review " + ", ".join( f"{path}" for path in locations ) + if metric["not_covered"]: + return f"| Changed production lines | ⚠️ Review | {details} |" + return f"| Changed production lines | ✅ Covered | {details} |" + + +def render_summary( + summary: dict, + thresholds: dict, + tests_result: str, + tests_detail: str, + report: dict | None, +) -> str: + rows = [ + "### Reviewer summary", + "", + "| Signal | Result | Details |", + "|---|:---:|---|", + f"| Coverage smoke tests | {tests_result} | {tests_detail} |", + policy_row( summary, thresholds ), + comparison_row( report ), + changed_lines_row( report ), + "", + "The policy row is the enforced gate. Baseline and changed-line signals " + "are reviewer guidance; detailed diagnostics appear below.", + "", + ] + return "\n".join( rows ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Render the concise LLVM coverage review summary." + ) + parser.add_argument( "--coverage-summary", required=True, type=pathlib.Path ) + parser.add_argument( "--thresholds", required=True, type=pathlib.Path ) + parser.add_argument( "--pr-report", type=pathlib.Path ) + parser.add_argument( "--console", action="store_true" ) + parser.add_argument( "--tests-result" ) + parser.add_argument( "--tests-detail" ) + arguments = parser.parse_args() + + try: + raw_summary = read_json( arguments.coverage_summary, "coverage summary" ) + raw_policy = read_json( arguments.thresholds, "coverage policy" ) + summary = coverage_contract.validate_summary( raw_summary ) + thresholds = coverage_contract.validate_policy( raw_policy, summary["scope"] ) + if arguments.console: + print( render_console_summary( summary, thresholds ), end="" ) + return 0 + if arguments.tests_result is None or arguments.tests_detail is None: + raise ValueError( "--tests-result and --tests-detail are required" ) + report = ( + read_json( arguments.pr_report, "pull-request coverage report" ) + if arguments.pr_report is not None + else None + ) + print( + render_summary( + summary, + thresholds, + arguments.tests_result, + arguments.tests_detail, + report, + ), + end="", + ) + return 0 + except ( OSError, ValueError ) as error: + print( f"Coverage review summary error: {error}", file=sys.stderr ) + return 1 + + +if __name__ == "__main__": + raise SystemExit( main() ) diff --git a/scripts/tests/test_check_coverage_thresholds.py b/scripts/tests/test_check_coverage_thresholds.py new file mode 100755 index 00000000000..0b77b3c7cc3 --- /dev/null +++ b/scripts/tests/test_check_coverage_thresholds.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +import importlib.util +import json +import pathlib +import unittest + + +SCRIPT = pathlib.Path( __file__ ).parents[1] / "check_coverage_thresholds.py" +REPOSITORY_ROOT = pathlib.Path( __file__ ).parents[2] +SPEC = importlib.util.spec_from_file_location( "coverage_gate", SCRIPT ) +coverage_gate = importlib.util.module_from_spec( SPEC ) +SPEC.loader.exec_module( coverage_gate ) + + +def metric( covered, total ): + return { + "covered": covered, + "total": total, + "not_covered": total - covered, + "percent": round( 100.0 * covered / total if total else 0.0, 6 ), + } + + +def measurement( scope, excluded_regex ): + value = { + "commit_sha": "1" * 40, + "tree_sha": "2" * 40, + "contract_id": coverage_gate.COVERAGE_CONTRACT_ID, + "contract_fingerprint": "0" * 64, + "container": { + "image": "geosx/ubuntu24.04-clang20:349-1014", + "image_id": f"sha256:{'3' * 64}", + "image_digests": [ + f"geosx/ubuntu24.04-clang20@sha256:{'4' * 64}" + ], + }, + "toolchain": { + "c_compiler_version": ( + "Ubuntu clang version 20.1.2 (0ubuntu1~24.04.3)\n" + "Target: x86_64-pc-linux-gnu" + ), + "cxx_compiler_version": ( + "Ubuntu clang version 20.1.2 (0ubuntu1~24.04.3)\n" + "Target: x86_64-pc-linux-gnu" + ), + "llvm_cov_version": ( + "Ubuntu LLVM version 20.1.2\n Optimized build." + ), + "llvm_package_versions": [ + "libclang-rt-20-dev=1:20.1.2-0ubuntu1~24.04.3", + "llvm-20=1:20.1.2-0ubuntu1~24.04.3", + ], + "compiler_target": "x86_64-pc-linux-gnu", + }, + "build_config": { + "BLT_CXX_STD": "c++17", + "BUILD_SHARED_LIBS": True, + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_C_FLAGS": "-pthread", + "CMAKE_C_FLAGS_RELWITHDEBINFO": "-O2 -g -DNDEBUG", + "CMAKE_CXX_FLAGS": "-pthread", + "CMAKE_CXX_FLAGS_RELWITHDEBINFO": "-O2 -g -DNDEBUG", + "ENABLE_COVERAGE": False, + "ENABLE_CUDA": False, + "ENABLE_HIP": False, + "ENABLE_HYPRE": True, + "ENABLE_MPI": True, + "ENABLE_OPENMP": True, + "ENABLE_HYPRE_DEVICE": "CPU", + "ENABLE_TRILINOS": False, + "GEOS_BUILD_SHARED_LIBS": True, + "GEOS_ENABLE_BOUNDS_CHECK": False, + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE": True, + "GEOS_GLOBALINDEX_TYPE": "long long int", + "GEOS_LA_INTERFACE": "Hypre", + "GEOS_LOCALINDEX_TYPE": "int", + "LVARRAY_BOUNDS_CHECK": False, + "RAJA_ENABLE_CUDA": False, + "RAJA_ENABLE_HIP": False, + "RAJA_ENABLE_OPENMP": False, + }, + "metric_semantics": json.loads( + json.dumps( coverage_gate.METRIC_SEMANTICS ) + ), + "object_selection": json.loads( + json.dumps( coverage_gate.OBJECT_SELECTION ) + ), + } + value["contract_fingerprint"] = coverage_gate.compute_contract_fingerprint( + scope, excluded_regex, value + ) + return value + + +def summary( branch_covered=1, branch_total=2 ): + scope = "src/coreComponents" + excluded_regex = "/unitTests/" + canonical = { + "regions": metric( 1, 2 ), + "functions": metric( 1, 2 ), + "lines": metric( 1, 2 ), + "branches": metric( branch_covered, branch_total ), + } + document = { + "schema_version": 3, + "scope": scope, + "excluded_regex": excluded_regex, + "measurement": measurement( scope, excluded_regex ), + "tool": { "name": "llvm-cov", "major": 20 }, + "inputs": { + "profiles": 10, + "coverage_objects": 2, + "zero_hash_mappings": 3, + }, + "metrics": canonical, + "per_file_metrics": [ + { + "path": "src/coreComponents/example.cpp", + "metrics": json.loads( json.dumps( canonical ) ), + } + ], + "top_branch_gaps": [], + "supplemental": { + "native_branch_outcomes": metric( 1, 4 ), + }, + } + if branch_covered < branch_total: + document["top_branch_gaps"] = [ + { + "path": "src/coreComponents/example.cpp", + **metric( branch_covered, branch_total ), + } + ] + return document + + +class CoverageThresholdTests( unittest.TestCase ): + def test_exact_threshold_passes( self ): + validated = coverage_gate.validate_summary( summary() ) + markdown, passed = coverage_gate.build_markdown( + validated, { "branches": 5000 } + ) + self.assertTrue( passed ) + self.assertIn( "50.00%", markdown ) + self.assertIn( "✅ Coverage policy passed", markdown ) + + def test_one_count_below_threshold_fails( self ): + validated = coverage_gate.validate_summary( summary( 49, 100 ) ) + markdown, passed = coverage_gate.build_markdown( + validated, { "branches": 5000 } + ) + self.assertFalse( passed ) + self.assertIn( "❌ Coverage policy failed", markdown ) + self.assertIn( "❌ Fail (-1.00 pp)", markdown ) + + def test_integer_comparison_avoids_rounding_up( self ): + validated = coverage_gate.validate_summary( summary( 1, 3 ) ) + markdown, passed = coverage_gate.build_markdown( + validated, { "branches": 3334 } + ) + self.assertFalse( passed ) + self.assertIn( "❌ Fail (<0.01 pp below)", markdown ) + + def test_checked_in_threshold_boundary_is_exact( self ): + passing = coverage_gate.validate_summary( summary( 5001, 10000 ) ) + failing = coverage_gate.validate_summary( summary( 5000, 10000 ) ) + self.assertTrue( + coverage_gate.build_markdown( passing, { "branches": 5001 } )[1] + ) + self.assertFalse( + coverage_gate.build_markdown( failing, { "branches": 5001 } )[1] + ) + + def test_inconsistent_counts_are_rejected( self ): + document = summary() + document["metrics"]["branches"]["not_covered"] = 99 + with self.assertRaisesRegex( ValueError, "inconsistent" ): + coverage_gate.validate_summary( document ) + + def test_boolean_schema_version_is_rejected( self ): + document = summary() + document["schema_version"] = True + with self.assertRaisesRegex( ValueError, "integer 3" ): + coverage_gate.validate_summary( document ) + + def test_invalid_metadata_is_rejected( self ): + document = summary() + document["inputs"]["profiles"] = 0 + with self.assertRaisesRegex( ValueError, "inputs.profiles" ): + coverage_gate.validate_summary( document ) + + def test_contract_fingerprint_is_stable_and_excludes_revision_inputs( self ): + document = summary() + self.assertEqual( + document["measurement"]["contract_fingerprint"], + "392f58cf380615f8214dc716273efbadcabefb0f25d6bfdd83560077f1cd4672", + ) + + document["measurement"]["commit_sha"] = "a" * 40 + document["measurement"]["tree_sha"] = "b" * 40 + document["measurement"]["container"]["image"] = "local/relabel:latest" + document["inputs"]["profiles"] = 999 + document["inputs"]["coverage_objects"] = 77 + validated = coverage_gate.validate_summary( document ) + self.assertEqual( validated["measurement"]["commit_sha"], "a" * 40 ) + + def test_hard_contract_changes_require_a_new_fingerprint( self ): + mutations = ( + lambda document: document["measurement"]["build_config"].__setitem__( + "ENABLE_HYPRE", False + ), + lambda document: document["measurement"]["toolchain"].__setitem__( + "compiler_target", "aarch64-unknown-linux-gnu" + ), + lambda document: document["measurement"]["toolchain"].__setitem__( + "llvm_package_versions", [ "llvm-20=20.1.3" ] + ), + lambda document: document["measurement"]["container"].__setitem__( + "image_id", f"sha256:{'5' * 64}" + ), + ) + for mutate in mutations: + with self.subTest( mutation=mutate ): + document = summary() + mutate( document ) + with self.assertRaisesRegex( ValueError, "fingerprint" ): + coverage_gate.validate_summary( document ) + + def test_measurement_contract_fields_are_strictly_validated( self ): + document = summary() + document["measurement"]["contract_id"] = "other-contract" + with self.assertRaisesRegex( ValueError, "contract_id" ): + coverage_gate.validate_summary( document ) + + document = summary() + document["measurement"]["toolchain"]["llvm_cov_version"] = ( + "Ubuntu LLVM version 19.1.0" + ) + with self.assertRaisesRegex( ValueError, "does not match tool.major" ): + coverage_gate.validate_summary( document ) + + document = summary() + package = document["measurement"]["toolchain"]["llvm_package_versions"][0] + document["measurement"]["toolchain"]["llvm_package_versions"] = [ + package, + package, + ] + with self.assertRaisesRegex( ValueError, "sorted and unique" ): + coverage_gate.validate_summary( document ) + + document = summary() + digest = document["measurement"]["container"]["image_digests"][0] + document["measurement"]["container"]["image_digests"] = [ digest, digest ] + with self.assertRaisesRegex( ValueError, "sorted and unique" ): + coverage_gate.validate_summary( document ) + + def test_build_config_must_be_normalized_and_relevant( self ): + document = summary() + document["measurement"]["build_config"]["ENABLE_COVERAGE"] = "OFF" + with self.assertRaisesRegex( ValueError, "ENABLE_COVERAGE" ): + coverage_gate.validate_summary( document ) + + document = summary() + document["measurement"]["build_config"]["CMAKE_INSTALL_PREFIX"] = "/tmp" + with self.assertRaisesRegex( ValueError, "irrelevant key" ): + coverage_gate.validate_summary( document ) + + def test_inconsistent_percent_is_rejected( self ): + document = summary() + document["metrics"]["branches"]["percent"] = 99.0 + with self.assertRaisesRegex( ValueError, "percent is inconsistent" ): + coverage_gate.validate_summary( document ) + + def test_per_file_metrics_are_sorted_unique_and_in_scope( self ): + document = summary() + document["per_file_metrics"] = [ + { + "path": "src/coreComponents/z.cpp", + "metrics": { + name: metric( 0, 1 ) for name in coverage_gate.CANONICAL_METRICS + }, + }, + { + "path": "src/coreComponents/a.cpp", + "metrics": { + name: metric( 1, 1 ) for name in coverage_gate.CANONICAL_METRICS + }, + }, + ] + document["top_branch_gaps"] = [ + { "path": "src/coreComponents/z.cpp", **metric( 0, 1 ) } + ] + with self.assertRaisesRegex( ValueError, "sorted by path" ): + coverage_gate.validate_summary( document ) + + document = summary() + document["per_file_metrics"][0]["path"] = "outside.cpp" + with self.assertRaisesRegex( ValueError, "outside the scope" ): + coverage_gate.validate_summary( document ) + + document = summary() + duplicate = json.loads( json.dumps( document["per_file_metrics"][0] ) ) + document["per_file_metrics"].append( duplicate ) + with self.assertRaisesRegex( ValueError, "paths must be unique" ): + coverage_gate.validate_summary( document ) + + def test_per_file_metrics_must_reconcile_every_canonical_metric( self ): + for name in coverage_gate.CANONICAL_METRICS: + with self.subTest( metric=name ): + document = summary() + document["per_file_metrics"][0]["metrics"][name] = metric( 0, 2 ) + with self.assertRaisesRegex( ValueError, f"{name} covered" ): + coverage_gate.validate_summary( document ) + + def test_per_file_zero_denominators_are_allowed( self ): + document = summary() + document["per_file_metrics"].insert( + 0, + { + "path": "src/coreComponents/empty.cpp", + "metrics": { + name: metric( 0, 0 ) for name in coverage_gate.CANONICAL_METRICS + }, + }, + ) + validated = coverage_gate.validate_summary( document ) + self.assertEqual( + validated["per_file_metrics"][0]["metrics"]["branches"]["total"], + 0, + ) + + def test_top_branch_gaps_must_match_per_file_metrics( self ): + document = summary() + document["top_branch_gaps"][0] = { + "path": "src/coreComponents/example.cpp", + **metric( 0, 2 ), + } + with self.assertRaisesRegex( ValueError, "does not match canonical per-file" ): + coverage_gate.validate_summary( document ) + + def test_policy_scope_must_match( self ): + policy = { + "schema_version": 1, + "scope": "wrong/scope", + "minimum_basis_points": { "branches": 5000 }, + } + with self.assertRaisesRegex( ValueError, "does not match" ): + coverage_gate.validate_policy( policy, "src/coreComponents" ) + + def test_non_gated_metrics_are_report_only( self ): + validated = coverage_gate.validate_summary( summary() ) + markdown, passed = coverage_gate.build_markdown( + validated, { "branches": 5000 } + ) + self.assertTrue( passed ) + self.assertIn( "Functions", markdown ) + self.assertIn( "ℹ️ Measured", markdown ) + + def test_lines_and_functions_thresholds_are_enforced( self ): + validated = coverage_gate.validate_summary( summary() ) + markdown, passed = coverage_gate.build_markdown( + validated, + { "lines": 5001, "functions": 5001, "branches": 5000 }, + ) + + self.assertFalse( passed ) + self.assertIn( "| Lines | 1 | 1 | 2 | 50.00% | ≥ 50.01% | ❌ Fail", markdown ) + self.assertIn( + "| Functions | 1 | 1 | 2 | 50.00% | ≥ 50.01% | ❌ Fail", markdown + ) + + def test_markdown_golden_output( self ): + validated = coverage_gate.validate_summary( summary() ) + markdown, passed = coverage_gate.build_markdown( + validated, { "branches": 5000 } + ) + expected = """### ✅ Coverage policy passed + +Production scope: `src/coreComponents` · Reporter: `llvm-cov 20` + +| Metric | Covered | Missed | Total | Coverage | Requirement | Result | +|---|---:|---:|---:|---:|---:|:---:| +| Lines | 1 | 1 | 2 | 50.00% | — | ℹ️ Measured | +| Functions | 1 | 1 | 2 | 50.00% | — | ℹ️ Measured | +| Canonical branches | 1 | 1 | 2 | 50.00% | ≥ 50.00% | ✅ Pass (+0.00 pp) | +| Regions | 1 | 1 | 2 | 50.00% | — | ℹ️ Measured | + +Percentages are rounded for display; policy thresholds use exact covered/total counts. + +### Largest branch gaps + +| Source file | Covered | Missed | Total | Coverage | +|---|---:|---:|---:|---:| +| example.cpp | 1 | 1 | 2 | 50.00% | + +Ranked by missed canonical branch outcomes within the production scope. + +### Coverage collection + +| Input | Value | +|---|---:| +| Profile files merged | 10 | +| Instrumented product objects | 2 | +| Audited zero-hash mappings | 3 | + +Zero-hash mappings have no profile counters and are audited during report generation; any nonzero or unaudited mismatch fails the job. + +
+Branch diagnostic (not gated) + +| Diagnostic | Covered | Total | Coverage | +|---|---:|---:|---:| +| Native branch outcomes | 1 | 4 | 25.00% | + +Native outcomes are instantiation-weighted: C++ template and inline-function instances may appear more than once. This diagnostic is not the policy metric. + +
+""" + self.assertTrue( passed ) + self.assertEqual( markdown, expected ) + + def test_markdown_presents_counts_inputs_and_diagnostics( self ): + document = summary( 12, 23 ) + document["inputs"] = { + "profiles": 3451, + "coverage_objects": 29, + "zero_hash_mappings": 29966, + } + document["metrics"]["functions"] = metric( 10887, 14276 ) + document["supplemental"]["native_branch_outcomes"] = metric( + 48401, 684500 + ) + document["top_branch_gaps"] = [ + { + "path": "src/coreComponents/a.cpp", + **metric( 10, 20 ), + }, + { + "path": "src/coreComponents/nested/b.cpp", + **metric( 2, 3 ), + }, + ] + document["per_file_metrics"] = [ + { + "path": "src/coreComponents/a.cpp", + "metrics": { + "regions": metric( 1, 2 ), + "functions": metric( 10887, 14276 ), + "lines": metric( 1, 2 ), + "branches": metric( 10, 20 ), + }, + }, + { + "path": "src/coreComponents/nested/b.cpp", + "metrics": { + "regions": metric( 0, 0 ), + "functions": metric( 0, 0 ), + "lines": metric( 0, 0 ), + "branches": metric( 2, 3 ), + }, + }, + ] + validated = coverage_gate.validate_summary( document ) + markdown, passed = coverage_gate.build_markdown( + validated, { "branches": 5001 } + ) + + self.assertTrue( passed ) + self.assertIn( + "| Functions | 10,887 | 3,389 | 14,276 | 76.26% | — | ℹ️ Measured |", + markdown, + ) + self.assertIn( "### Largest branch gaps", markdown ) + self.assertLess( markdown.index( "a.cpp" ), markdown.index( "nested/b.cpp" ) ) + self.assertIn( "| a.cpp | 10 | 10 | 20 | 50.00% |", markdown ) + self.assertIn( "| Profile files merged | 3,451 |", markdown ) + self.assertIn( "| Instrumented product objects | 29 |", markdown ) + self.assertIn( "| Audited zero-hash mappings | 29,966 |", markdown ) + self.assertIn( "| Native branch outcomes | 48,401 | 684,500 | 7.07% |", markdown ) + primary_table, diagnostic = markdown.split( "
", maxsplit=1 ) + self.assertNotIn( "Native branch outcomes", primary_table ) + self.assertIn( "instantiation-weighted", diagnostic ) + + def test_branch_gap_paths_must_be_unique_and_in_scope( self ): + document = summary() + document["top_branch_gaps"][0]["path"] = "src/tests/not-production.cpp" + with self.assertRaisesRegex( ValueError, "outside the scope" ): + coverage_gate.validate_summary( document ) + + document = summary() + document["top_branch_gaps"].append( dict( document["top_branch_gaps"][0] ) ) + with self.assertRaisesRegex( ValueError, "normalized and unique" ): + coverage_gate.validate_summary( document ) + + for invalid_path in ( + "src/coreComponents/", + "src/coreComponents//file.cpp", + "src/coreComponents/./file.cpp", + ): + with self.subTest( path=invalid_path ): + document = summary() + document["top_branch_gaps"][0]["path"] = invalid_path + with self.assertRaisesRegex( ValueError, "normalized" ): + coverage_gate.validate_summary( document ) + + def test_branch_gaps_must_be_sorted_by_missed_count( self ): + document = summary() + document["top_branch_gaps"] = [ + { "path": "src/coreComponents/small.cpp", **metric( 2, 3 ) }, + { "path": "src/coreComponents/large.cpp", **metric( 1, 4 ) }, + ] + with self.assertRaisesRegex( ValueError, "sorted" ): + coverage_gate.validate_summary( document ) + + def test_branch_gap_totals_cannot_exceed_canonical_branches( self ): + document = summary() + document["top_branch_gaps"] = [ + { "path": "src/coreComponents/too-large.cpp", **metric( 0, 3 ) } + ] + with self.assertRaisesRegex( ValueError, "exceed canonical" ): + coverage_gate.validate_summary( document ) + + def test_checked_in_policy_is_valid( self ): + policy_path = REPOSITORY_ROOT / ".github" / "coverage-thresholds.json" + with policy_path.open( encoding="utf-8" ) as stream: + policy = json.load( stream ) + thresholds = coverage_gate.validate_policy( policy, "src/coreComponents" ) + self.assertEqual( + thresholds, + { "lines": 7001, "functions": 7501, "branches": 5001 }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_compare_pr_coverage.py b/scripts/tests/test_compare_pr_coverage.py new file mode 100755 index 00000000000..15e541ee404 --- /dev/null +++ b/scripts/tests/test_compare_pr_coverage.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +from __future__ import annotations + +import importlib.util +import json +import pathlib +import stat +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = pathlib.Path( __file__ ).parents[1] / "compare_pr_coverage.py" +SPEC = importlib.util.spec_from_file_location( "compare_pr_coverage", SCRIPT ) +compare_pr_coverage = importlib.util.module_from_spec( SPEC ) +SPEC.loader.exec_module( compare_pr_coverage ) + + +def run_command( repository: pathlib.Path, *arguments: str ) -> str: + result = subprocess.run( + [ *arguments ], + cwd=repository, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return result.stdout.strip() + + +def metric( covered: int, total: int ) -> dict: + return { + "covered": covered, + "not_covered": total - covered, + "total": total, + "percent": round( 100.0 * covered / total, 6 ), + } + + +class GitFixture: + def __init__( self ) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.repository = pathlib.Path( self.temporary.name ) / "repository" + self.repository.mkdir() + run_command( self.repository, "git", "init", "-q", "-b", "develop" ) + run_command( + self.repository, "git", "config", "user.email", "coverage@example.com" + ) + run_command( + self.repository, "git", "config", "user.name", "Coverage Tests" + ) + + def close( self ) -> None: + self.temporary.cleanup() + + def write( self, path: str, contents: str ) -> pathlib.Path: + target = self.repository / path + target.parent.mkdir( parents=True, exist_ok=True ) + target.write_text( contents, encoding="utf-8" ) + return target + + def commit( self, message: str ) -> str: + run_command( self.repository, "git", "add", "--all" ) + run_command( self.repository, "git", "commit", "-q", "-m", message ) + return run_command( self.repository, "git", "rev-parse", "HEAD" ) + + def tree( self, commit_sha: str ) -> str: + return run_command( + self.repository, "git", "rev-parse", f"{commit_sha}^{{tree}}" + ) + + +def summary_document( + commit_sha: str, + tree_sha: str, + *, + schema_version: int = 3, + image_digit: str = "3", + scope: str = "src/coreComponents", + covered_offset: int = 0, +) -> dict: + document = { + "schema_version": schema_version, + "scope": scope, + "excluded_regex": "/(unitTests|tests)/", + "tool": { "name": "llvm-cov", "major": 20 }, + "inputs": { + "profiles": 4, + "coverage_objects": 2, + "zero_hash_mappings": 0, + }, + "metrics": { + "regions": metric( 50 + covered_offset, 100 ), + "functions": metric( 60 + covered_offset, 100 ), + "lines": metric( 70 + covered_offset, 100 ), + "branches": metric( 40 + covered_offset, 100 ), + }, + "top_branch_gaps": [], + "supplemental": { + "native_branch_outcomes": metric( 5 + covered_offset, 100 ) + }, + } + if schema_version >= 3: + measurement = { + "commit_sha": commit_sha, + "tree_sha": tree_sha, + "contract_id": compare_pr_coverage.coverage_contract.COVERAGE_CONTRACT_ID, + "contract_fingerprint": "0" * 64, + "container": { + "image": "geosx/ubuntu24.04-clang20:test", + "image_id": f"sha256:{image_digit * 64}", + "image_digests": [ + f"geosx/ubuntu24.04-clang20@sha256:{'4' * 64}" + ], + }, + "toolchain": { + "c_compiler_version": "Ubuntu clang version 20.1.2", + "cxx_compiler_version": "Ubuntu clang version 20.1.2", + "llvm_cov_version": "Ubuntu LLVM version 20.1.2", + "llvm_package_versions": [ + "libclang-rt-20-dev=1:20.1.2", + "llvm-20=1:20.1.2", + ], + "compiler_target": "x86_64-pc-linux-gnu", + }, + "build_config": { + "BLT_CXX_STD": "c++17", + "BUILD_SHARED_LIBS": True, + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_C_FLAGS": "-pthread", + "CMAKE_C_FLAGS_RELWITHDEBINFO": "-O2 -g -DNDEBUG", + "CMAKE_CXX_FLAGS": "-pthread", + "CMAKE_CXX_FLAGS_RELWITHDEBINFO": "-O2 -g -DNDEBUG", + "ENABLE_COVERAGE": False, + "ENABLE_CUDA": False, + "ENABLE_HIP": False, + "ENABLE_HYPRE": True, + "ENABLE_MPI": True, + "ENABLE_OPENMP": True, + "ENABLE_TRILINOS": False, + "GEOS_BUILD_SHARED_LIBS": True, + "GEOS_ENABLE_BOUNDS_CHECK": False, + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE": True, + "GEOS_GLOBALINDEX_TYPE": "long long int", + "GEOS_LA_INTERFACE": "Hypre", + "GEOS_LOCALINDEX_TYPE": "int", + "LVARRAY_BOUNDS_CHECK": False, + "RAJA_ENABLE_CUDA": False, + "RAJA_ENABLE_HIP": False, + "RAJA_ENABLE_OPENMP": False, + }, + "metric_semantics": json.loads( + json.dumps( compare_pr_coverage.coverage_contract.METRIC_SEMANTICS ) + ), + "object_selection": json.loads( + json.dumps( compare_pr_coverage.coverage_contract.OBJECT_SELECTION ) + ), + } + measurement["contract_fingerprint"] = ( + compare_pr_coverage.coverage_contract.compute_contract_fingerprint( + scope, document["excluded_regex"], measurement + ) + ) + document["measurement"] = measurement + document["per_file_metrics"] = [ + { + "path": "src/coreComponents/New.cpp", + "metrics": json.loads( json.dumps( document["metrics"] ) ), + } + ] + document["top_branch_gaps"] = [ + { + "path": "src/coreComponents/New.cpp", + **document["metrics"]["branches"], + } + ] + return document + + +class ComparePrCoverageTest( unittest.TestCase ): + def setUp( self ) -> None: + self.fixture = GitFixture() + + def tearDown( self ) -> None: + self.fixture.close() + + def write_json( self, name: str, document: dict ) -> pathlib.Path: + path = pathlib.Path( self.fixture.temporary.name ) / name + path.write_text( json.dumps( document ), encoding="utf-8" ) + return path + + def write_lcov( self, contents: str ) -> pathlib.Path: + path = pathlib.Path( self.fixture.temporary.name ) / "native.info" + path.write_text( contents, encoding="utf-8" ) + return path + + def make_added_source( self, path: str = "src/coreComponents/New.cpp" ) -> tuple: + self.fixture.write( "README", "base\n" ) + base_sha = self.fixture.commit( "base" ) + source = self.fixture.write( + path, + "int uncovered() {\n" + " if ( flag ) {\n" + " return 1;\n" + " }\n" + " return 0;\n" + "}\n", + ) + head_sha = self.fixture.commit( "add source" ) + return base_sha, head_sha, source + + def candidate_summary( + self, head_sha: str, *, schema_version: int = 3, offset: int = 0 + ) -> pathlib.Path: + return self.write_json( + "candidate.json", + summary_document( + head_sha, + self.fixture.tree( head_sha ), + schema_version=schema_version, + covered_offset=offset, + ), + ) + + def test_patch_metrics_and_actionable_locations( self ) -> None: + base_sha, head_sha, source = self.make_added_source() + lcov = self.write_lcov( + f"SF:{source}\n" + "FN:1,uncovered\n" + "FNDA:0,uncovered\n" + "DA:1,0\n" + "DA:2,1\n" + "DA:3,0\n" + "DA:5,1\n" + "BRDA:2,0,0,1\n" + "BRDA:2,0,1,0\n" + "end_of_record\n" + ) + report, markdown = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + self.candidate_summary( head_sha ), + ) + + patch = report["patch"] + self.assertEqual( patch["changed_files"], 1 ) + self.assertEqual( patch["changed_lines"], 6 ) + self.assertEqual( + patch["metrics"]["executable_lines"], metric( 2, 4 ) + ) + self.assertEqual( + patch["metrics"]["function_start_sites"], metric( 0, 1 ) + ) + self.assertEqual( + patch["metrics"]["native_branch_outcomes"], metric( 1, 2 ) + ) + self.assertEqual( patch["non_instrumented_changed_lines"], 2 ) + self.assertIn( "Add cases that exercise the remaining", markdown ) + self.assertIn( "Advisory and instantiation-weighted", markdown ) + self.assertFalse( report["aggregate_comparison"]["comparable"] ) + self.assertIn( + "No exact-base", report["aggregate_comparison"]["reason"] + ) + + def test_exact_baseline_comparison( self ) -> None: + base_sha, head_sha, source = self.make_added_source() + lcov = self.write_lcov( + f"SF:{source}\nDA:1,1\nend_of_record\n" + ) + baseline = self.write_json( + "baseline.json", + summary_document( base_sha, self.fixture.tree( base_sha ) ), + ) + candidate = self.candidate_summary( head_sha, offset=5 ) + report, markdown = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + candidate, + baseline, + ) + + comparison = report["aggregate_comparison"] + self.assertTrue( comparison["comparable"] ) + self.assertEqual( comparison["metrics"]["branches"]["covered_delta"], 5 ) + self.assertEqual( + comparison["metrics"]["branches"]["not_covered_delta"], -5 + ) + self.assertEqual( + comparison["metrics"]["branches"]["percentage_point_delta"], 5.0 + ) + self.assertIn( "| Canonical branches |", markdown ) + self.assertIn( "+5.00 pp", markdown ) + self.assertIn( "##### Canonical movement in changed files", markdown ) + self.assertIn( "| src/coreComponents/New.cpp", markdown ) + + def test_incompatible_baseline_is_explicitly_not_comparable( self ) -> None: + base_sha, head_sha, source = self.make_added_source() + lcov = self.write_lcov( + f"SF:{source}\nDA:1,1\nend_of_record\n" + ) + baseline = self.write_json( + "baseline.json", + summary_document( + base_sha, + self.fixture.tree( base_sha ), + image_digit="5", + ), + ) + report, markdown = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + self.candidate_summary( head_sha ), + baseline, + ) + + comparison = report["aggregate_comparison"] + self.assertFalse( comparison["comparable"] ) + self.assertIn( "fingerprints differ", comparison["reason"] ) + self.assertIn( "ℹ️ N/A", markdown ) + + def test_tampered_v3_baseline_is_rejected_by_contract_validator( self ) -> None: + base_sha, head_sha, source = self.make_added_source() + lcov = self.write_lcov( f"SF:{source}\nDA:1,1\nend_of_record\n" ) + baseline_document = summary_document( + base_sha, self.fixture.tree( base_sha ) + ) + baseline_document["measurement"]["container"]["image_id"] = ( + f"sha256:{'9' * 64}" + ) + baseline = self.write_json( "baseline.json", baseline_document ) + report, _ = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + self.candidate_summary( head_sha ), + baseline, + ) + + comparison = report["aggregate_comparison"] + self.assertFalse( comparison["comparable"] ) + self.assertIn( "baseline summary is invalid", comparison["reason"] ) + self.assertIn( "fingerprint", comparison["reason"] ) + + def test_legacy_v2_summaries_make_exact_delta_unavailable( self ) -> None: + base_sha, head_sha, source = self.make_added_source() + lcov = self.write_lcov( + f"SF:{source}\nDA:1,1\nend_of_record\n" + ) + baseline = self.write_json( + "baseline.json", + summary_document( + base_sha, self.fixture.tree( base_sha ), schema_version=2 + ), + ) + report, _ = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + self.candidate_summary( head_sha, schema_version=2 ), + baseline, + ) + + comparison = report["aggregate_comparison"] + self.assertFalse( comparison["comparable"] ) + self.assertIn( "schema-3 measurement provenance", comparison["reason"] ) + + def test_rename_uses_only_modified_head_lines( self ) -> None: + original = "\n".join( f"int value_{index} = {index};" for index in range( 10 ) ) + self.fixture.write( "src/coreComponents/Old.cpp", original + "\n" ) + base_sha = self.fixture.commit( "base" ) + run_command( + self.fixture.repository, + "git", + "mv", + "src/coreComponents/Old.cpp", + "src/coreComponents/New.cpp", + ) + updated = original.replace( "int value_4 = 4;", "int value_4 = 40;" ) + self.fixture.write( "src/coreComponents/New.cpp", updated + "\n" ) + head_sha = self.fixture.commit( "rename and edit" ) + + records = compare_pr_coverage.changed_line_ranges( + self.fixture.repository, base_sha, head_sha + ) + self.assertEqual( len( records ), 1 ) + self.assertEqual( records[0]["status"], "R" ) + self.assertEqual( records[0]["old_path"], "src/coreComponents/Old.cpp" ) + self.assertEqual( records[0]["path"], "src/coreComponents/New.cpp" ) + self.assertEqual( records[0]["lines"], frozenset( ( 5, ) ) ) + + def test_noninstrumented_only_patch_has_empty_executable_metric( self ) -> None: + source = self.fixture.write( + "src/coreComponents/Comment.cpp", + "// old comment\nint stable() { return 1; }\n", + ) + base_sha = self.fixture.commit( "base" ) + self.fixture.write( + "src/coreComponents/Comment.cpp", + "// new comment\nint stable() { return 1; }\n", + ) + head_sha = self.fixture.commit( "comment only" ) + lcov = self.write_lcov( + f"SF:{source}\n" + "FN:2,stable\nFNDA:1,stable\nDA:2,1\nend_of_record\n" + ) + report, markdown = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + self.candidate_summary( head_sha ), + ) + + lines = report["patch"]["metrics"]["executable_lines"] + self.assertEqual( lines["total"], 0 ) + self.assertIsNone( lines["percent"] ) + self.assertEqual( report["patch"]["non_instrumented_changed_lines"], 1 ) + self.assertIn( "| Executable changed lines | 0 | 0 | 0 | N/A |", markdown ) + + def test_test_sources_are_excluded_from_production_patch_totals( self ) -> None: + self.fixture.write( "README", "base\n" ) + base_sha = self.fixture.commit( "base" ) + self.fixture.write( + "src/coreComponents/unitTests/testFeature.cpp", + "TEST( Feature, Covered ) {}\n", + ) + head_sha = self.fixture.commit( "add unit test" ) + lcov = self.write_lcov( "" ) + report, markdown = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + self.candidate_summary( head_sha ), + ) + + patch = report["patch"] + self.assertEqual( patch["changed_files"], 0 ) + self.assertEqual( patch["changed_lines"], 0 ) + self.assertEqual( patch["excluded_changed_files"], 1 ) + self.assertEqual( patch["excluded_changed_lines"], 1 ) + self.assertIn( "test/example/support files", markdown ) + + def test_markdown_escapes_paths_and_function_names( self ) -> None: + path = "src/coreComponents/evil|.cpp" + base_sha, head_sha, source = self.make_added_source( path ) + lcov = self.write_lcov( + f"SF:{source}\n" + "FN:1,bad|\nFNDA:0,bad|\nDA:1,0\nend_of_record\n" + ) + _, markdown = compare_pr_coverage.analyze( + self.fixture.repository, + base_sha, + head_sha, + lcov, + self.candidate_summary( head_sha ), + ) + + self.assertIn( "evil|<x>.cpp", markdown ) + self.assertIn( "bad|<T>", markdown ) + self.assertNotIn( "evil|.cpp", markdown ) + self.assertNotIn( "bad|", markdown ) + + def test_cli_writes_compact_outputs_and_rejects_short_sha( self ) -> None: + base_sha, head_sha, source = self.make_added_source() + lcov = self.write_lcov( + f"SF:{source}\nDA:1,1\nend_of_record\n" + ) + candidate = self.candidate_summary( head_sha ) + output_json = pathlib.Path( self.fixture.temporary.name ) / "diff.json" + output_markdown = pathlib.Path( self.fixture.temporary.name ) / "diff.md" + result = subprocess.run( + [ + sys.executable, + str( SCRIPT ), + "--repository", + str( self.fixture.repository ), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + "--native-info", + str( lcov ), + "--candidate-summary", + str( candidate ), + "--output-json", + str( output_json ), + "--output-markdown", + str( output_markdown ), + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual( result.returncode, 0, result.stderr ) + self.assertEqual( result.stdout, "" ) + self.assertEqual( json.loads( output_json.read_text() )["schema_version"], 1 ) + self.assertIn( "Pull-request coverage", output_markdown.read_text() ) + self.assertLess( output_json.stat().st_size, 20_000 ) + self.assertEqual( stat.S_IMODE( output_json.stat().st_mode ), 0o644 ) + self.assertEqual( stat.S_IMODE( output_markdown.stat().st_mode ), 0o644 ) + + invalid = subprocess.run( + [ + sys.executable, + str( SCRIPT ), + "--repository", + str( self.fixture.repository ), + "--base-sha", + base_sha[:12], + "--head-sha", + head_sha, + "--native-info", + str( lcov ), + "--candidate-summary", + str( candidate ), + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual( invalid.returncode, 2 ) + self.assertIn( "full 40-character Git SHA", invalid.stderr ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_fetch_coverage_baseline.py b/scripts/tests/test_fetch_coverage_baseline.py new file mode 100755 index 00000000000..b441e7e8958 --- /dev/null +++ b/scripts/tests/test_fetch_coverage_baseline.py @@ -0,0 +1,739 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +import contextlib +import importlib.util +import io +import json +import pathlib +import stat +import tempfile +import unittest +import urllib.error +import urllib.parse +import urllib.request +import warnings +import zipfile +from unittest import mock + + +SCRIPT = pathlib.Path( __file__ ).parents[1] / "fetch_coverage_baseline.py" +SPEC = importlib.util.spec_from_file_location( "coverage_baseline", SCRIPT ) +coverage_baseline = importlib.util.module_from_spec( SPEC ) +SPEC.loader.exec_module( coverage_baseline ) +CHECKER_TEST_SCRIPT = pathlib.Path( __file__ ).with_name( + "test_check_coverage_thresholds.py" +) +CHECKER_TEST_SPEC = importlib.util.spec_from_file_location( + "coverage_threshold_fixtures", CHECKER_TEST_SCRIPT +) +coverage_threshold_fixtures = importlib.util.module_from_spec( CHECKER_TEST_SPEC ) +CHECKER_TEST_SPEC.loader.exec_module( coverage_threshold_fixtures ) + +BASE_SHA = "0123456789abcdef0123456789abcdef01234567" +OTHER_SHA = "fedcba9876543210fedcba9876543210fedcba98" +PREFIX = "llvm-source-coverage-baseline-" + + +class FakeResponse: + def __init__( + self, + payload, + *, + status=200, + content_type="application/json", + content_length=True, + final_url=None, + max_read_size=None, + read_error=None, + ): + self._stream = io.BytesIO( payload ) + self.status = status + self.headers = { "Content-Type": content_type } + if content_length: + self.headers["Content-Length"] = str( len( payload ) ) + self.final_url = final_url + self.max_read_size = max_read_size + self.read_error = read_error + + def __enter__( self ): + return self + + def __exit__( self, exception_type, exception, traceback ): + return False + + def getcode( self ): + return self.status + + def geturl( self ): + return self.final_url + + def read( self, size=-1 ): + if self.read_error is not None: + raise self.read_error + if self.max_read_size is not None and ( + size < 0 or size > self.max_read_size + ): + size = self.max_read_size + return self._stream.read( size ) + + +class QueueOpener: + def __init__( self, responses ): + self.responses = list( responses ) + self.requests = [] + + def open( self, request, timeout ): + self.requests.append( ( request, timeout ) ) + if not self.responses: + raise AssertionError( f"unexpected request: {request.full_url}" ) + response = self.responses.pop( 0 ) + if isinstance( response, BaseException ): + raise response + if callable( response ): + response = response( request ) + if response.final_url is None: + response.final_url = request.full_url + return response + + +def json_response( document, **kwargs ): + return FakeResponse( json.dumps( document ).encode( "utf-8" ), **kwargs ) + + +def workflow_run( run_id=101, sha=BASE_SHA, **overrides ): + document = { + "conclusion": "success", + "event": "push", + "head_branch": "develop", + "head_sha": sha, + "id": run_id, + "run_attempt": 1, + "status": "completed", + } + document.update( overrides ) + return document + + +def artifact( artifact_id=202, name=None, expired=False ): + return { + "expired": expired, + "id": artifact_id, + "name": name if name is not None else f"{PREFIX}{BASE_SHA}", + } + + +def coverage_summary( revision=BASE_SHA, include_revision=True ): + document = ( + coverage_threshold_fixtures.summary() + if include_revision + else { "schema_version": 2 } + ) + if include_revision: + document["measurement"]["commit_sha"] = revision + return json.dumps( document, sort_keys=True ).encode( "utf-8" ) + + +def make_zip( entries ): + output = io.BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter( "ignore", UserWarning ) + with zipfile.ZipFile( output, "w" ) as bundle: + for entry in entries: + if len( entry ) == 2: + name, payload = entry + bundle.writestr( name, payload, compress_type=zipfile.ZIP_DEFLATED ) + else: + name, payload, mode = entry + information = zipfile.ZipInfo( name ) + information.create_system = 3 + information.external_attr = mode << 16 + information.compress_type = zipfile.ZIP_DEFLATED + bundle.writestr( information, payload ) + return output.getvalue() + + +def successful_responses( archive, *, artifact_document=None ): + artifacts = ( + [artifact()] if artifact_document is None else artifact_document + ) + return [ + json_response( { "total_count": 1, "workflow_runs": [workflow_run()] } ), + json_response( { "artifacts": artifacts, "total_count": len( artifacts ) } ), + FakeResponse( archive, content_type="application/zip" ), + ] + + +def cli_arguments( output_dir ): + return [ + "--repository", + "LLNL/GEOS", + "--workflow", + "ci_tests.yml", + "--base-sha", + BASE_SHA, + "--artifact-name-prefix", + PREFIX, + "--token-env", + "TEST_TOKEN", + "--output-dir", + str( output_dir ), + ] + + +def run_main( opener, output_dir, environment=None ): + standard_output = io.StringIO() + standard_error = io.StringIO() + if environment is None: + environment = { "TEST_TOKEN": "secret-token" } + with contextlib.redirect_stdout( standard_output ), contextlib.redirect_stderr( + standard_error + ): + status = coverage_baseline.main( + cli_arguments( output_dir ), environ=environment, opener=opener + ) + return status, standard_output.getvalue(), standard_error.getvalue() + + +class CoverageBaselineFetchTests( unittest.TestCase ): + def test_success_fetches_only_exact_flat_files( self ): + archive = make_zip( + [ + ( "coverage-summary.json", coverage_summary() ), + ( "llvm-summary.json", b'{"data": []}\n' ), + ( "toolchain.txt", b"LLVM 20.1.2\n" ), + ] + ) + wrong_name = artifact( + 200, name=f"{PREFIX}{BASE_SHA}-extra", expired=False + ) + opener = QueueOpener( + successful_responses( archive, artifact_document=[wrong_name, artifact()] ) + ) + with tempfile.TemporaryDirectory() as temporary_directory: + output_dir = pathlib.Path( temporary_directory ) + ( output_dir / "toolchain.txt" ).write_text( "stale", encoding="utf-8" ) + status, standard_output, standard_error = run_main( opener, output_dir ) + + self.assertEqual( status, 0 ) + self.assertEqual( standard_error, "" ) + self.assertIn( f"{PREFIX}{BASE_SHA}", standard_output ) + self.assertEqual( + json.loads( + ( output_dir / "coverage-summary.json" ).read_text( + encoding="utf-8" + ) + )["measurement"]["commit_sha"], + BASE_SHA, + ) + self.assertEqual( + ( output_dir / "toolchain.txt" ).read_text( encoding="utf-8" ), + "LLVM 20.1.2\n", + ) + status_document = json.loads( + ( output_dir / coverage_baseline.STATUS_FILE ).read_text( + encoding="utf-8" + ) + ) + self.assertEqual( status_document["status"], "found" ) + self.assertEqual( status_document["run_id"], 101 ) + self.assertEqual( status_document["artifact_id"], 202 ) + + self.assertEqual( len( opener.requests ), 3 ) + runs_url = urllib.parse.urlsplit( opener.requests[0][0].full_url ) + query = urllib.parse.parse_qs( runs_url.query ) + self.assertEqual( query["head_sha"], [BASE_SHA] ) + self.assertEqual( query["branch"], ["develop"] ) + self.assertEqual( query["event"], ["push"] ) + self.assertEqual( query["status"], ["completed"] ) + self.assertEqual( + opener.requests[2][0].full_url, + "https://api.github.com/repos/LLNL/GEOS/actions/artifacts/202/zip", + ) + self.assertEqual( + opener.requests[0][0].get_header( "Authorization" ), + "Bearer secret-token", + ) + + def test_schema_without_embedded_revision_is_accepted( self ): + archive = make_zip( + [ + ( + "coverage-summary.json", + coverage_summary( include_revision=False ), + ) + ] + ) + files = coverage_baseline.read_safe_archive( archive, BASE_SHA ) + self.assertEqual( set( files ), { "coverage-summary.json" } ) + + def test_schema_three_requires_embedded_revision( self ): + document = coverage_threshold_fixtures.summary() + del document["measurement"] + archive = make_zip( + [( "coverage-summary.json", json.dumps( document ).encode( "utf-8" ) )] + ) + with self.assertRaisesRegex( + coverage_baseline.BaselineFetchError, "measurement" + ): + coverage_baseline.read_safe_archive( archive, BASE_SHA ) + + def test_missing_run_is_exit_three_and_clears_stale_outputs( self ): + opener = QueueOpener( + [json_response( { "total_count": 0, "workflow_runs": [] } )] + ) + with tempfile.TemporaryDirectory() as temporary_directory: + output_dir = pathlib.Path( temporary_directory ) + ( output_dir / "coverage-summary.json" ).write_text( + "stale", encoding="utf-8" + ) + status, _, standard_error = run_main( opener, output_dir ) + self.assertEqual( status, 3 ) + self.assertIn( "unavailable", standard_error ) + self.assertFalse( ( output_dir / "coverage-summary.json" ).exists() ) + reason = json.loads( + ( output_dir / coverage_baseline.STATUS_FILE ).read_text( + encoding="utf-8" + ) + ) + self.assertEqual( reason["status"], "missing" ) + self.assertEqual( reason["reason_code"], "workflow_run_missing" ) + self.assertEqual( reason["base_sha"], BASE_SHA ) + + def test_expired_or_inexact_artifact_is_exit_three( self ): + cases = ( + ( + [artifact( expired=True )], + "artifact_expired", + ), + ( + [artifact( name=f"{PREFIX}{BASE_SHA}-not-exact" )], + "artifact_missing", + ), + ) + for artifacts, reason_code in cases: + with self.subTest( reason_code=reason_code ): + opener = QueueOpener( + [ + json_response( + { "total_count": 1, "workflow_runs": [workflow_run()] } + ), + json_response( + { "artifacts": artifacts, "total_count": len( artifacts ) } + ), + ] + ) + with tempfile.TemporaryDirectory() as temporary_directory: + output_dir = pathlib.Path( temporary_directory ) + status, _, _ = run_main( opener, output_dir ) + self.assertEqual( status, 3 ) + reason = json.loads( + ( output_dir / coverage_baseline.STATUS_FILE ).read_text( + encoding="utf-8" + ) + ) + self.assertEqual( reason["reason_code"], reason_code ) + + def test_download_404_is_a_missing_baseline( self ): + download_url = ( + "https://api.github.com/repos/LLNL/GEOS/actions/artifacts/202/zip" + ) + missing_responses = ( + urllib.error.HTTPError( download_url, 404, "missing", {}, None ), + FakeResponse( b"", status=410, content_type="application/zip" ), + ) + for missing_response in missing_responses: + with self.subTest( response=missing_response ): + opener = QueueOpener( + [ + json_response( + { + "total_count": 1, + "workflow_runs": [workflow_run()], + } + ), + json_response( + { "artifacts": [artifact()], "total_count": 1 } + ), + missing_response, + ] + ) + with tempfile.TemporaryDirectory() as temporary_directory: + output_dir = pathlib.Path( temporary_directory ) + status, _, _ = run_main( opener, output_dir ) + self.assertEqual( status, 3 ) + reason = json.loads( + ( output_dir / coverage_baseline.STATUS_FILE ).read_text( + encoding="utf-8" + ) + ) + self.assertEqual( + reason["reason_code"], "artifact_download_missing" + ) + + def test_http_and_json_failures_are_exit_two( self ): + runs_url = ( + "https://api.github.com/repos/LLNL/GEOS/actions/workflows/" + "ci_tests.yml/runs" + ) + cases = ( + QueueOpener( + [urllib.error.HTTPError( runs_url, 403, "forbidden", {}, None )] + ), + QueueOpener( + [FakeResponse( b"not json", content_type="application/json" )] + ), + QueueOpener( + [json_response( { "workflow_runs": [], "total_count": "zero" } )] + ), + ) + for opener in cases: + with self.subTest( opener=opener ): + with tempfile.TemporaryDirectory() as temporary_directory: + status, _, standard_error = run_main( + opener, pathlib.Path( temporary_directory ) + ) + self.assertEqual( status, 2 ) + self.assertIn( "coverage baseline error", standard_error ) + + def test_response_content_type_final_url_and_size_are_strict( self ): + document = { "total_count": 0, "workflow_runs": [] } + cases = ( + json_response( document, content_type="text/plain" ), + json_response( document, final_url="https://example.com/runs" ), + json_response( + document, + final_url="https://api.github.com:invalid/runs", + ), + FakeResponse( + b"{}", + content_type="application/json", + content_length=False, + ), + ) + cases[3].headers["Content-Length"] = str( + coverage_baseline.JSON_RESPONSE_LIMIT + 1 + ) + for response in cases: + with self.subTest( response=response ): + client = coverage_baseline.GitHubClient( + "token", opener=QueueOpener( [response] ) + ) + with self.assertRaises( coverage_baseline.BaselineFetchError ): + client.get_json( "/repos/LLNL/GEOS/actions/runs" ) + + def test_partial_reads_are_completed_and_read_failures_are_wrapped( self ): + document = { "total_count": 0, "workflow_runs": [] } + client = coverage_baseline.GitHubClient( + "token", + opener=QueueOpener( + [json_response( document, max_read_size=3 )] + ), + ) + self.assertEqual( + client.get_json( "/repos/LLNL/GEOS/actions/runs" ), document + ) + + client = coverage_baseline.GitHubClient( + "token", + opener=QueueOpener( + [FakeResponse( b"", read_error=OSError( "read failed" ) )] + ), + ) + with self.assertRaisesRegex( + coverage_baseline.BaselineFetchError, "response failed" + ): + client.get_json( "/repos/LLNL/GEOS/actions/runs" ) + + def test_latest_exact_completed_develop_push_is_selected( self ): + runs = [ + workflow_run( 100, conclusion="failure" ), + workflow_run( 101, head_branch="other" ), + workflow_run( 102, sha=OTHER_SHA ), + workflow_run( 103, run_attempt=2 ), + workflow_run( 104, run_attempt=1 ), + ] + client = coverage_baseline.GitHubClient( + "token", + opener=QueueOpener( + [json_response( { "total_count": len( runs ), "workflow_runs": runs } )] + ), + ) + selected = coverage_baseline.find_exact_run( + client, "/repos/LLNL/GEOS", "ci_tests.yml", BASE_SHA + ) + self.assertEqual( selected["id"], 104 ) + + def test_pagination_finds_an_exact_run_on_a_later_page( self ): + with mock.patch.object( coverage_baseline, "PER_PAGE", 2 ): + opener = QueueOpener( + [ + json_response( + { + "total_count": 3, + "workflow_runs": [ + workflow_run( 1, sha=OTHER_SHA ), + workflow_run( 2, sha=OTHER_SHA ), + ], + } + ), + json_response( + { + "total_count": 3, + "workflow_runs": [workflow_run( 3 )], + } + ), + ] + ) + client = coverage_baseline.GitHubClient( "token", opener=opener ) + selected = coverage_baseline.find_exact_run( + client, "/repos/LLNL/GEOS", "12345", BASE_SHA + ) + self.assertEqual( selected["id"], 3 ) + second_query = urllib.parse.parse_qs( + urllib.parse.urlsplit( opener.requests[1][0].full_url ).query + ) + self.assertEqual( second_query["page"], ["2"] ) + + def test_pagination_rejects_a_short_incomplete_response( self ): + client = coverage_baseline.GitHubClient( + "token", + opener=QueueOpener( + [ + json_response( + { + "total_count": 2, + "workflow_runs": [workflow_run()], + } + ) + ] + ), + ) + with self.assertRaisesRegex( + coverage_baseline.BaselineFetchError, "before total_count" + ): + coverage_baseline.find_exact_run( + client, "/repos/LLNL/GEOS", "ci_tests.yml", BASE_SHA + ) + + def test_duplicate_exact_artifacts_are_rejected( self ): + client = coverage_baseline.GitHubClient( + "token", + opener=QueueOpener( + [ + json_response( + { + "artifacts": [artifact( 1 ), artifact( 2 )], + "total_count": 2, + } + ) + ] + ), + ) + with self.assertRaisesRegex( + coverage_baseline.BaselineFetchError, "multiple" + ): + coverage_baseline.find_exact_artifact( + client, + "/repos/LLNL/GEOS", + 10, + f"{PREFIX}{BASE_SHA}", + ) + + def test_zip_rejects_unexpected_traversal_duplicate_and_missing_entries( self ): + valid_summary = coverage_summary() + archives = ( + make_zip( [( "../coverage-summary.json", valid_summary )] ), + make_zip( + [ + ( "coverage-summary.json", valid_summary ), + ( "unexpected.txt", b"unexpected" ), + ] + ), + make_zip( + [ + ( "coverage-summary.json", valid_summary ), + ( "coverage-summary.json", valid_summary ), + ] + ), + make_zip( [( "toolchain.txt", b"LLVM" )] ), + ) + for archive in archives: + with self.subTest(): + with self.assertRaises( coverage_baseline.BaselineFetchError ): + coverage_baseline.read_safe_archive( archive, BASE_SHA ) + + def test_zip_rejects_symlinks_and_special_files( self ): + modes = ( stat.S_IFLNK | 0o777, stat.S_IFIFO | 0o600 ) + for mode in modes: + archive = make_zip( + [( "coverage-summary.json", b"target", mode )] + ) + with self.subTest( mode=mode ): + with self.assertRaisesRegex( + coverage_baseline.BaselineFetchError, "regular file" + ): + coverage_baseline.read_safe_archive( archive, BASE_SHA ) + + dos_directory = zipfile.ZipInfo( "coverage-summary.json" ) + dos_directory.create_system = 0 + dos_directory.external_attr = 0x10 + self.assertFalse( coverage_baseline._is_regular_zip_entry( dos_directory ) ) + + unknown_system = zipfile.ZipInfo( "coverage-summary.json" ) + unknown_system.create_system = 7 + self.assertFalse( coverage_baseline._is_regular_zip_entry( unknown_system ) ) + + def test_zip_rejects_invalid_json_revision_mismatch_and_malformed_zip( self ): + archives = ( + make_zip( [( "coverage-summary.json", b"not json" )] ), + make_zip( + [( "coverage-summary.json", coverage_summary( OTHER_SHA ) )] + ), + b"not a zip archive", + ) + for archive in archives: + with self.subTest(): + with self.assertRaises( coverage_baseline.BaselineFetchError ): + coverage_baseline.read_safe_archive( archive, BASE_SHA ) + + def test_zip_bomb_is_rejected_by_uncompressed_limit( self ): + oversized = b" " * ( coverage_baseline.DOWNLOAD_LIMIT + 1 ) + archive = make_zip( [( "coverage-summary.json", oversized )] ) + self.assertLess( len( archive ), coverage_baseline.DOWNLOAD_LIMIT ) + with self.assertRaisesRegex( + coverage_baseline.BaselineFetchError, "extraction limit" + ): + coverage_baseline.read_safe_archive( archive, BASE_SHA ) + + def test_download_body_is_limited_even_without_content_length( self ): + oversized = b"x" * ( coverage_baseline.DOWNLOAD_LIMIT + 1 ) + response = FakeResponse( + oversized, + content_type="application/zip", + content_length=False, + final_url="https://objects.example.test/artifact.zip", + ) + client = coverage_baseline.GitHubClient( + "token", opener=QueueOpener( [response] ) + ) + with self.assertRaisesRegex( + coverage_baseline.BaselineFetchError, "download limit" + ): + client.download_artifact( "/repos/LLNL/GEOS", 1 ) + + def test_download_response_type_and_url_are_strict( self ): + responses = ( + FakeResponse( b"zip", content_type="text/plain" ), + FakeResponse( + b"zip", + content_type="application/zip", + final_url="http://objects.example.test/artifact.zip", + ), + ) + for response in responses: + with self.subTest( response=response ): + client = coverage_baseline.GitHubClient( + "token", opener=QueueOpener( [response] ) + ) + with self.assertRaises( coverage_baseline.BaselineFetchError ): + client.download_artifact( "/repos/LLNL/GEOS", 1 ) + + def test_cross_origin_redirect_strips_authorization( self ): + handler = coverage_baseline.SafeRedirectHandler() + request = urllib.request.Request( + "https://api.github.com/repos/LLNL/GEOS/actions/artifacts/1/zip", + headers={ + "Authorization": "Bearer secret", + "Cookie": "session=secret", + "Proxy-Authorization": "Basic secret", + "X-GitHub-Api-Version": coverage_baseline.API_VERSION, + "User-Agent": "test", + }, + ) + redirected = handler.redirect_request( + request, + None, + 302, + "Found", + {}, + "https://objects.example.test/artifact.zip", + ) + headers = { + name.lower(): value for name, value in redirected.header_items() + } + self.assertNotIn( "authorization", headers ) + self.assertNotIn( "cookie", headers ) + self.assertNotIn( "proxy-authorization", headers ) + self.assertNotIn( "x-github-api-version", headers ) + self.assertEqual( headers["user-agent"], "test" ) + unsafe_urls = ( + "http://example.test/artifact.zip", + "https://@objects.example.test/artifact.zip", + "https://api.github.com:invalid/artifact.zip", + "https://[malformed/artifact.zip", + ) + for unsafe_url in unsafe_urls: + with self.subTest( unsafe_url=unsafe_url ): + with self.assertRaises( coverage_baseline.BaselineFetchError ): + handler.redirect_request( + request, None, 302, "Found", {}, unsafe_url + ) + + def test_unsafe_inputs_and_output_symlink_are_exit_two( self ): + invalid_argument_sets = ( + ( "--repository", "../GEOS" ), + ( "--workflow", "../ci_tests.yml" ), + ( "--base-sha", "short" ), + ( "--artifact-name-prefix", "bad/name" ), + ( "--token-env", "BAD-NAME" ), + ) + for option, value in invalid_argument_sets: + with self.subTest( option=option ): + with tempfile.TemporaryDirectory() as temporary_directory: + arguments = cli_arguments( pathlib.Path( temporary_directory ) ) + arguments[arguments.index( option ) + 1] = value + with contextlib.redirect_stderr( io.StringIO() ): + status = coverage_baseline.main( + arguments, + environ={ "TEST_TOKEN": "token", "BAD-NAME": "token" }, + opener=QueueOpener( [] ), + ) + self.assertEqual( status, 2 ) + + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path( temporary_directory ) + target = root / "target" + target.mkdir() + output_link = root / "output" + output_link.symlink_to( target, target_is_directory=True ) + with contextlib.redirect_stderr( io.StringIO() ): + status = coverage_baseline.main( + cli_arguments( output_link ), + environ={ "TEST_TOKEN": "token" }, + opener=QueueOpener( [] ), + ) + self.assertEqual( status, 2 ) + + def test_missing_token_is_exit_two_without_an_http_request( self ): + environments = ( + {}, + { "TEST_TOKEN": "token\nInjected: value" }, + { "TEST_TOKEN": "token\0suffix" }, + ) + for environment in environments: + with self.subTest( environment=environment ): + opener = QueueOpener( [] ) + with tempfile.TemporaryDirectory() as temporary_directory: + status, _, standard_error = run_main( + opener, + pathlib.Path( temporary_directory ), + environment=environment, + ) + self.assertEqual( status, 2 ) + self.assertIn( "missing or empty", standard_error ) + self.assertEqual( opener.requests, [] ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_render_coverage_report_html.py b/scripts/tests/test_render_coverage_report_html.py new file mode 100644 index 00000000000..0631c1766ac --- /dev/null +++ b/scripts/tests/test_render_coverage_report_html.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +import importlib.util +import json +import pathlib +import tempfile +import unittest + + +SCRIPT = pathlib.Path( __file__ ).parents[1] / "render_coverage_report_html.py" +SPEC = importlib.util.spec_from_file_location( "coverage_html", SCRIPT ) +coverage_html = importlib.util.module_from_spec( SPEC ) +SPEC.loader.exec_module( coverage_html ) + + +def metric( covered: int, total: int ) -> dict: + return { + "covered": covered, + "not_covered": total - covered, + "total": total, + "percent": round( 100.0 * covered / total, 6 ) if total else 0.0, + } + + +def summary() -> dict: + return { + "schema_version": 3, + "scope": "src/coreComponents", + "tool": { "name": "llvm-cov", "major": 20 }, + "metrics": { + "lines": metric( 74, 100 ), + "functions": metric( 76, 100 ), + "branches": metric( 55, 100 ), + "regions": metric( 50, 100 ), + }, + "inputs": { + "profiles": 106, + "coverage_objects": 29, + "zero_hash_mappings": 3, + }, + "supplemental": { "native_branch_outcomes": metric( 7, 100 ) }, + "top_branch_gaps": [ + { + "path": "src/coreComponents/example.cpp", + **metric( 1, 2 ), + } + ], + "measurement": { "commit_sha": "a" * 40 }, + } + + +def pull_request_report() -> dict: + return { + "base_sha": "b" * 40, + "head_sha": "a" * 40, + "scope": "src/coreComponents", + "aggregate_comparison": { + "comparable": False, + "reason": "No exact-base coverage summary was provided.", + }, + "patch": { + "changed_files": 1, + "changed_lines": 2, + "non_instrumented_changed_lines": 0, + "metrics": { + "executable_lines": metric( 0, 1 ), + "function_start_sites": metric( 0, 0 ), + "native_branch_outcomes": metric( 0, 0 ), + }, + "top_impacted_files": [], + "top_impacted_locations": [ + { + "path": "src/coreComponents/example.cpp", + "line": 42, + "executable_line": True, + "line_covered": False, + "native_branch_outcomes": metric( 0, 0 ), + "function_start_covered": None, + "hint": "Add a focused coverage smoke.", + } + ], + }, + } + + +class CoverageHtmlTests( unittest.TestCase ): + def test_report_is_a_single_self_contained_human_readable_page( self ): + with tempfile.TemporaryDirectory() as temporary: + artifact_dir = pathlib.Path( temporary ) + ( artifact_dir / "coverage-summary.json" ).write_text( + json.dumps( summary() ), encoding="utf-8" + ) + ( artifact_dir / "pr-coverage.json" ).write_text( + json.dumps( pull_request_report() ), encoding="utf-8" + ) + ( artifact_dir / "coverage-status.md" ).write_text( + """### ✅ LLVM source coverage passed + +| Validation | Result | Details | +|---|:---:|---| +| Coverage smoke suite | ✅ Pass | 106/106 passed in 10.00 s | + +### Run configuration + +| Item | Value | +|---|---| +| Toolchain | `Clang 20 / LLVM 20` | +""", + encoding="utf-8", + ) + ( artifact_dir / "ctest.log" ).write_text( + "100% tests passed, 0 tests failed out of 106\n", encoding="utf-8" + ) + ( artifact_dir / "mapping-integrity.log" ).write_text( + "mapping checks passed\n", encoding="utf-8" + ) + output_path = artifact_dir / "index.html" + output_path.write_text( "old report\n", encoding="utf-8" ) + output_path.chmod( 0o400 ) + output = coverage_html.render_html( artifact_dir ) + coverage_html.write_atomic( output_path, output ) + self.assertIn( "", output_path.read_text() ) + + self.assertIn( "", output ) + self.assertIn( "Enforced policy", output ) + self.assertIn( "74.00%", output ) + self.assertIn( "No exact-base coverage summary was provided.", output ) + self.assertIn( "example.cpp:42", output ) + self.assertIn( "mapping checks passed", output ) + self.assertIn( "details", output ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_render_coverage_review_summary.py b/scripts/tests/test_render_coverage_review_summary.py new file mode 100644 index 00000000000..4fa2b591794 --- /dev/null +++ b/scripts/tests/test_render_coverage_review_summary.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +import importlib.util +import pathlib +import unittest + + +SCRIPT = pathlib.Path( __file__ ).parents[1] / "render_coverage_review_summary.py" +SPEC = importlib.util.spec_from_file_location( "coverage_review_summary", SCRIPT ) +coverage_review_summary = importlib.util.module_from_spec( SPEC ) +SPEC.loader.exec_module( coverage_review_summary ) + + +def metric( covered: int, total: int ) -> dict: + return { + "covered": covered, + "not_covered": total - covered, + "total": total, + "percent": round( 100.0 * covered / total, 6 ), + } + + +def summary() -> dict: + return { + "scope": "src/coreComponents", + "metrics": { + "regions": metric( 50, 100 ), + "functions": metric( 76, 100 ), + "lines": metric( 74, 100 ), + "branches": metric( 55, 100 ), + }, + } + + +def unavailable_report() -> dict: + return { + "aggregate_comparison": { + "comparable": False, + "reason": "No exact-base coverage summary was provided.", + }, + "patch": { + "metrics": { "executable_lines": metric( 0, 1 ) }, + "top_impacted_locations": [ + { + "path": "src/coreComponents/example.cpp", + "line": 42, + "executable_line": True, + "line_covered": False, + } + ], + }, + } + + +class CoverageReviewSummaryTests( unittest.TestCase ): + thresholds = { "lines": 7001, "functions": 7501, "branches": 5001 } + + def test_console_summary_is_plain_text_and_contains_enforced_metrics( self ): + rendered = coverage_review_summary.render_console_summary( + summary(), self.thresholds + ) + + self.assertIn( "LLVM source coverage policy", rendered ) + self.assertIn( "Overall result: ✅ PASS", rendered ) + self.assertIn( "Lines", rendered ) + self.assertIn( "74.00%", rendered ) + self.assertIn( "74 / 100", rendered ) + self.assertIn( ">= 70.01%", rendered ) + self.assertIn( "Functions", rendered ) + self.assertIn( "Canonical branches", rendered ) + self.assertNotIn( "|---", rendered ) + + def test_console_summary_marks_a_failed_threshold( self ): + failing_summary = summary() + failing_summary["metrics"]["lines"]["covered"] = 69 + failing_summary["metrics"]["lines"]["not_covered"] = 31 + failing_summary["metrics"]["lines"]["percent"] = 69.0 + + rendered = coverage_review_summary.render_console_summary( + failing_summary, self.thresholds + ) + + self.assertIn( "Overall result: ❌ FAIL", rendered ) + self.assertIn( "❌ Fail", rendered ) + + def test_summary_separates_policy_baseline_and_changed_line_signals( self ): + rendered = coverage_review_summary.render_summary( + summary(), + self.thresholds, + "✅ Pass", + "106/106 passed in 106.37 s", + unavailable_report(), + ) + + self.assertIn( "### Reviewer summary", rendered ) + self.assertIn( "106/106 passed in 106.37 s", rendered ) + self.assertIn( "✅ Lines 74.00% (74/100) ≥ 70.01%", rendered ) + self.assertIn( "✅ Functions 76.00% (76/100) ≥ 75.01%", rendered ) + self.assertIn( "✅ Canonical branches 55.00% (55/100) ≥ 50.01%", rendered ) + self.assertIn( "ℹ️ Unavailable", rendered ) + self.assertIn( "No exact-base baseline artifact was available.", rendered ) + self.assertIn( "⚠️ Review", rendered ) + self.assertIn( "example.cpp:42", rendered ) + + def test_summary_reports_a_project_coverage_regression( self ): + report = unavailable_report() + report["aggregate_comparison"] = { + "comparable": True, + "metrics": { + name: { "percentage_point_delta": delta } + for name, delta in ( + ( "lines", -0.10 ), + ( "functions", -0.05 ), + ( "branches", -0.20 ), + ( "regions", -0.01 ), + ) + }, + } + + rendered = coverage_review_summary.render_summary( + summary(), self.thresholds, "✅ Pass", "106/106 passed", report + ) + + self.assertIn( "⚠️ Decreased", rendered ) + self.assertIn( "Lines -0.10 pp", rendered ) + self.assertIn( "Canonical branches -0.20 pp", rendered ) + + def test_non_pull_request_summary_has_no_pr_warning( self ): + rendered = coverage_review_summary.render_summary( + summary(), self.thresholds, "✅ Pass", "106/106 passed", None + ) + + self.assertIn( "⏭️ Not applicable", rendered ) + self.assertIn( "not a pull-request coverage run", rendered ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/cmake/GeosxOptions.cmake b/src/cmake/GeosxOptions.cmake index 3e41db757d2..826495faa36 100644 --- a/src/cmake/GeosxOptions.cmake +++ b/src/cmake/GeosxOptions.cmake @@ -6,8 +6,35 @@ message( "CMAKE_HOST_APPLE = ${CMAKE_HOST_APPLE}" ) ### OPTIONS ### option( GEOS_ENABLE_FPE "Enables floating point exceptions" ON ) option( GEOS_ENABLE_TESTS "Enables unit tests" ON ) +option( GEOS_ENABLE_LLVM_SOURCE_COVERAGE + "Enables Clang source-based code coverage instrumentation" OFF ) option( ENABLE_CALIPER "Enables Caliper instrumentation" OFF ) +if( GEOS_ENABLE_LLVM_SOURCE_COVERAGE ) + if( NOT CMAKE_C_COMPILER_ID MATCHES "Clang" OR + NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang" ) + message( FATAL_ERROR + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE requires Clang C and C++ compilers." ) + endif() + if( ENABLE_CUDA OR ENABLE_HIP ) + message( FATAL_ERROR + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE currently supports CPU builds only." ) + endif() + if( ENABLE_COVERAGE ) + message( FATAL_ERROR + "GEOS_ENABLE_LLVM_SOURCE_COVERAGE and BLT ENABLE_COVERAGE use " + "different instrumentation formats and cannot both be enabled." ) + endif() + + # Keep this mode separate from BLT's gcov-compatible ENABLE_COVERAGE mode. + # Source-based coverage records source conditions rather than compiler CFG + # arcs and is consumed by llvm-profdata/llvm-cov. + add_compile_options( + $<$:-fprofile-instr-generate> + $<$:-fcoverage-mapping> ) + add_link_options( $<$:-fprofile-instr-generate> ) +endif() + option( ENABLE_MATHPRESSO "" ON ) option( ENABLE_CHAI "Enables CHAI" ON ) diff --git a/src/coreComponents/dataRepository/unitTests/testPacking.cpp b/src/coreComponents/dataRepository/unitTests/testPacking.cpp index 32b31f7c1b6..8b24d012986 100644 --- a/src/coreComponents/dataRepository/unitTests/testPacking.cpp +++ b/src/coreComponents/dataRepository/unitTests/testPacking.cpp @@ -259,6 +259,210 @@ TEST( testPacking, testPackByIndexDevice ) } } +#define CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, NDIM, PERMUTATION ) \ + { \ + using ArrayType = Array< TYPE, NDIM, PERMUTATION >; \ + localIndex dimensions[NDIM]; \ + for( localIndex dimension = 0; dimension < NDIM; ++dimension ) \ + { \ + dimensions[dimension] = 2; \ + } \ + ArrayType source; \ + source.resize( NDIM, dimensions ); \ + for( localIndex valueIndex = 0; valueIndex < source.size(); ++valueIndex ) \ + { \ + source.data()[valueIndex] = static_cast< double >( valueIndex + 1 ); \ + } \ + \ + buffer_unit_type * nullBuffer = nullptr; \ + parallelDeviceEvents sizeEvents; \ + localIndex const fullCalculatedSize = \ + bufferOps::PackDevice< false >( nullBuffer, source.toViewConst(), sizeEvents ); \ + EXPECT_EQ( nullBuffer, nullptr ); \ + EXPECT_GT( fullCalculatedSize, 0 ); \ + \ + buffer_type fullBuffer( fullCalculatedSize ); \ + buffer_unit_type * fullWriteBuffer = fullBuffer.data(); \ + parallelDeviceEvents fullPackEvents; \ + localIndex const fullPackedSize = \ + bufferOps::PackDevice< true >( fullWriteBuffer, source.toViewConst(), fullPackEvents ); \ + EXPECT_EQ( fullPackedSize, fullCalculatedSize ); \ + EXPECT_EQ( fullWriteBuffer, fullBuffer.data() + fullPackedSize ); \ + waitAllDeviceEvents( fullPackEvents ); \ + \ + ArrayType fullDestination; \ + fullDestination.resize( NDIM, dimensions ); \ + buffer_unit_type const * fullReadBuffer = fullBuffer.data(); \ + parallelDeviceEvents fullUnpackEvents; \ + localIndex const fullUnpackedSize = \ + bufferOps::UnpackDevice( fullReadBuffer, fullDestination.toView(), fullUnpackEvents ); \ + EXPECT_EQ( fullUnpackedSize, fullPackedSize ); \ + EXPECT_EQ( fullReadBuffer, fullBuffer.data() + fullUnpackedSize ); \ + waitAllDeviceEvents( fullUnpackEvents ); \ + source.move( hostMemorySpace ); \ + fullDestination.move( hostMemorySpace ); \ + for( localIndex valueIndex = 0; valueIndex < source.size(); ++valueIndex ) \ + { \ + EXPECT_EQ( fullDestination.data()[valueIndex], source.data()[valueIndex] ); \ + } \ + \ + array1d< localIndex > indices( dimensions[0] ); \ + for( localIndex index = 0; index < indices.size(); ++index ) \ + { \ + indices[index] = index; \ + } \ + nullBuffer = nullptr; \ + parallelDeviceEvents indexedSizeEvents; \ + localIndex const indexedCalculatedSize = bufferOps::PackByIndexDevice< false >( \ + nullBuffer, source.toViewConst(), indices.toViewConst(), indexedSizeEvents ); \ + EXPECT_EQ( nullBuffer, nullptr ); \ + EXPECT_GT( indexedCalculatedSize, 0 ); \ + \ + buffer_type indexedBuffer( indexedCalculatedSize ); \ + buffer_unit_type * indexedWriteBuffer = indexedBuffer.data(); \ + parallelDeviceEvents indexedPackEvents; \ + localIndex const indexedPackedSize = bufferOps::PackByIndexDevice< true >( \ + indexedWriteBuffer, source.toViewConst(), indices.toViewConst(), indexedPackEvents ); \ + EXPECT_EQ( indexedPackedSize, indexedCalculatedSize ); \ + localIndex const indexedWrittenSize = indexedWriteBuffer - indexedBuffer.data(); \ + EXPECT_LE( indexedWrittenSize, indexedPackedSize ); \ + EXPECT_LT( indexedPackedSize - indexedWrittenSize, static_cast< localIndex >( sizeof( TYPE ) ) ); \ + waitAllDeviceEvents( indexedPackEvents ); \ + \ + ArrayType replaceDestination; \ + replaceDestination.resize( NDIM, dimensions ); \ + for( localIndex valueIndex = 0; valueIndex < replaceDestination.size(); ++valueIndex ) \ + { \ + replaceDestination.data()[valueIndex] = -1.0; \ + } \ + buffer_unit_type const * replaceReadBuffer = indexedBuffer.data(); \ + parallelDeviceEvents replaceEvents; \ + localIndex const replaceSize = bufferOps::UnpackByIndexDevice( \ + replaceReadBuffer, replaceDestination.toView(), indices.toViewConst(), replaceEvents, MPI_REPLACE ); \ + EXPECT_EQ( replaceSize, indexedPackedSize ); \ + localIndex const replaceReadSize = replaceReadBuffer - indexedBuffer.data(); \ + EXPECT_LE( replaceReadSize, replaceSize ); \ + EXPECT_LT( replaceSize - replaceReadSize, static_cast< localIndex >( sizeof( TYPE ) ) ); \ + waitAllDeviceEvents( replaceEvents ); \ + replaceDestination.move( hostMemorySpace ); \ + source.move( hostMemorySpace ); \ + for( localIndex valueIndex = 0; valueIndex < source.size(); ++valueIndex ) \ + { \ + EXPECT_EQ( replaceDestination.data()[valueIndex], source.data()[valueIndex] ); \ + } \ + \ + ArrayType sumDestination; \ + sumDestination.resize( NDIM, dimensions ); \ + for( localIndex valueIndex = 0; valueIndex < sumDestination.size(); ++valueIndex ) \ + { \ + sumDestination.data()[valueIndex] = 2.0; \ + } \ + buffer_unit_type const * sumReadBuffer = indexedBuffer.data(); \ + parallelDeviceEvents sumEvents; \ + localIndex const sumSize = bufferOps::UnpackByIndexDevice( \ + sumReadBuffer, sumDestination.toView(), indices.toViewConst(), sumEvents, MPI_SUM ); \ + EXPECT_EQ( sumSize, indexedPackedSize ); \ + localIndex const sumReadSize = sumReadBuffer - indexedBuffer.data(); \ + EXPECT_LE( sumReadSize, sumSize ); \ + EXPECT_LT( sumSize - sumReadSize, static_cast< localIndex >( sizeof( TYPE ) ) ); \ + waitAllDeviceEvents( sumEvents ); \ + sumDestination.move( hostMemorySpace ); \ + source.move( hostMemorySpace ); \ + for( localIndex valueIndex = 0; valueIndex < source.size(); ++valueIndex ) \ + { \ + TYPE expected = source.data()[valueIndex]; \ + expected += 2.0; \ + EXPECT_EQ( sumDestination.data()[valueIndex], expected ); \ + } \ + \ + ArrayType maxIncomingDestination; \ + maxIncomingDestination.resize( NDIM, dimensions ); \ + for( localIndex valueIndex = 0; valueIndex < maxIncomingDestination.size(); ++valueIndex ) \ + { \ + maxIncomingDestination.data()[valueIndex] = 0.0; \ + } \ + buffer_unit_type const * maxIncomingReadBuffer = indexedBuffer.data(); \ + parallelDeviceEvents maxIncomingEvents; \ + localIndex const maxIncomingSize = bufferOps::UnpackByIndexDevice( \ + maxIncomingReadBuffer, maxIncomingDestination.toView(), indices.toViewConst(), maxIncomingEvents, MPI_MAX ); \ + EXPECT_EQ( maxIncomingSize, indexedPackedSize ); \ + localIndex const maxIncomingReadSize = maxIncomingReadBuffer - indexedBuffer.data(); \ + EXPECT_LE( maxIncomingReadSize, maxIncomingSize ); \ + EXPECT_LT( maxIncomingSize - maxIncomingReadSize, static_cast< localIndex >( sizeof( TYPE ) ) ); \ + waitAllDeviceEvents( maxIncomingEvents ); \ + maxIncomingDestination.move( hostMemorySpace ); \ + source.move( hostMemorySpace ); \ + for( localIndex valueIndex = 0; valueIndex < source.size(); ++valueIndex ) \ + { \ + EXPECT_EQ( maxIncomingDestination.data()[valueIndex], source.data()[valueIndex] ); \ + } \ + \ + ArrayType maxExistingDestination; \ + maxExistingDestination.resize( NDIM, dimensions ); \ + for( localIndex valueIndex = 0; valueIndex < maxExistingDestination.size(); ++valueIndex ) \ + { \ + maxExistingDestination.data()[valueIndex] = 1000.0; \ + } \ + buffer_unit_type const * maxExistingReadBuffer = indexedBuffer.data(); \ + parallelDeviceEvents maxExistingEvents; \ + localIndex const maxExistingSize = bufferOps::UnpackByIndexDevice( \ + maxExistingReadBuffer, maxExistingDestination.toView(), indices.toViewConst(), maxExistingEvents, MPI_MAX ); \ + EXPECT_EQ( maxExistingSize, indexedPackedSize ); \ + localIndex const maxExistingReadSize = maxExistingReadBuffer - indexedBuffer.data(); \ + EXPECT_LE( maxExistingReadSize, maxExistingSize ); \ + EXPECT_LT( maxExistingSize - maxExistingReadSize, static_cast< localIndex >( sizeof( TYPE ) ) ); \ + waitAllDeviceEvents( maxExistingEvents ); \ + maxExistingDestination.move( hostMemorySpace ); \ + for( localIndex valueIndex = 0; valueIndex < maxExistingDestination.size(); ++valueIndex ) \ + { \ + TYPE expected; \ + expected = 1000.0; \ + EXPECT_EQ( maxExistingDestination.data()[valueIndex], expected ); \ + } \ + } + +#define CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_2D( TYPE ) \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 1, RAJA::PERM_I ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 2, RAJA::PERM_JI ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 2, RAJA::PERM_IJ ) + +#define CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_3D( TYPE ) \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_2D( TYPE ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 3, RAJA::PERM_JKI ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 3, RAJA::PERM_IKJ ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 3, RAJA::PERM_IJK ) + +#define CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_4D( TYPE ) \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_3D( TYPE ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 4, RAJA::PERM_JKLI ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 4, RAJA::PERM_IKLJ ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 4, RAJA::PERM_IJLK ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 4, RAJA::PERM_IJKL ) + +#define CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_5D( TYPE ) \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_4D( TYPE ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 5, RAJA::PERM_JKLMI ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 5, RAJA::PERM_IKLMJ ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 5, RAJA::PERM_IJLMK ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 5, RAJA::PERM_IJKML ); \ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS( TYPE, 5, RAJA::PERM_IJKLM ) + +TEST( testPacking, testExplicitDeviceInstantiationMatrix ) +{ + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_3D( int ); + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_3D( long int ); + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_3D( long long int ); + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_5D( real32 ); + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_5D( real64 ); + CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_3D( R1Tensor ); +} + +#undef CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_5D +#undef CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_4D +#undef CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_3D +#undef CHECK_EXPLICIT_DEVICE_BUFFER_OPS_UP_TO_2D +#undef CHECK_EXPLICIT_DEVICE_BUFFER_OPS + int main( int ac, char * av[] ) { diff --git a/src/coreComponents/dataRepository/unitTests/testWrapper.cpp b/src/coreComponents/dataRepository/unitTests/testWrapper.cpp index 4e17ded9543..4208404a00d 100644 --- a/src/coreComponents/dataRepository/unitTests/testWrapper.cpp +++ b/src/coreComponents/dataRepository/unitTests/testWrapper.cpp @@ -14,16 +14,372 @@ */ // Source includes +#include "common/TypeDispatch.hpp" #include "dataRepository/Group.hpp" #include "dataRepository/Wrapper.hpp" +#include "LvArray/src/system.hpp" // TPL includes #include #include +// System includes +#include +#include +#include + using namespace geos; using namespace dataRepository; +namespace +{ + +class ScopedThrowingLvArrayErrorHandler +{ +public: + ScopedThrowingLvArrayErrorHandler() + { + LvArray::system::setErrorHandler( []() + { + throw std::runtime_error( "Expected WrapperStandardArrays error" ); + } ); + } + + ~ScopedThrowingLvArrayErrorHandler() noexcept + { + LvArray::system::setErrorHandler( []() + { + std::abort(); + } ); + } + + ScopedThrowingLvArrayErrorHandler( ScopedThrowingLvArrayErrorHandler const & ) = delete; + ScopedThrowingLvArrayErrorHandler & operator=( ScopedThrowingLvArrayErrorHandler const & ) = delete; +}; + +template< typename ... ARRAY_TYPES > +void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) +{ + ScopedThrowingLvArrayErrorHandler const scopedErrorHandler; + + // Keep the arrays outside the Group so both the registered wrappers and their clones are + // non-owning. This makes it safe to exercise clone() without sharing ownership of the data. + std::tuple< ARRAY_TYPES ... > storage; + conduit::Node rootNode; + Group group( "Problem", rootNode ); + + localIndex wrapperIndex = 0; + std::apply( [&]( auto & ... value ) + { + ( group.registerWrapper( "standardArray" + std::to_string( wrapperIndex++ ), &value ), ... ); + }, storage ); + + xmlWrapper::xmlDocument emptyDocument; + xmlWrapper::xmlNodePos const noPosition( emptyDocument, + "", + xmlWrapper::xmlDocument::npos, + xmlWrapper::xmlDocument::npos, + xmlWrapper::xmlDocument::npos ); + xmlWrapper::xmlNode const emptyNode; + + string_array const dimLabels = { "first", "second" }; + array1d< localIndex > packIndices( 1 ); + packIndices[0] = 0; + parallelDeviceEvents events; + localIndex numWrappersTested = 0; + + group.forWrappers( [&]( WrapperBase & wrapper ) + { + ++numWrappersTested; + + EXPECT_FALSE( wrapper.getName().empty() ); + EXPECT_FALSE( wrapper.getPath().empty() ); + EXPECT_EQ( &wrapper.getParent(), &group ); + EXPECT_FALSE( wrapper.getSuccessfulReadFromInput() ); + EXPECT_FALSE( wrapper.processInputFile( emptyNode, noPosition ) ); + + int const numDims = wrapper.numArrayDims(); + ASSERT_GT( numDims, 0 ); + stdVector< localIndex > const dims( numDims, 2 ); + wrapper.resize( numDims, dims.data() ); + + EXPECT_GT( wrapper.size(), 0 ); + EXPECT_NE( wrapper.voidPointer(), nullptr ); + EXPECT_GT( wrapper.elementByteSize(), 0 ); + EXPECT_GT( wrapper.bytesAllocated(), 0 ); + + wrapper.resize( 3 ); + wrapper.resize( numDims, dims.data() ); + wrapper.reserve( 3 ); + EXPECT_GT( wrapper.capacity(), 0 ); + + // These validation failures occur before Array dimensions or storage are modified. + EXPECT_THROW( wrapper.resize( numDims + 1, dims.data() ), std::runtime_error ); + EXPECT_THROW( wrapper.resize( localIndex( -1 ) ), std::runtime_error ); + EXPECT_THROW( wrapper.move( static_cast< LvArray::MemorySpace >( -1 ), false ), std::runtime_error ); + + for( integer dim = 0; dim < numDims; ++dim ) + { + EXPECT_EQ( &wrapper.setDimLabels( dim, dimLabels ), &wrapper ); + Span< string const > const labels = wrapper.getDimLabels( dim ); + ASSERT_EQ( labels.size(), dimLabels.size() ); + EXPECT_EQ( labels[0], dimLabels[0] ); + EXPECT_EQ( labels[1], dimLabels[1] ); + } + + EXPECT_THROW( wrapper.setDimLabels( -1, dimLabels ), std::runtime_error ); + EXPECT_THROW( wrapper.setDimLabels( numDims, dimLabels ), std::runtime_error ); + EXPECT_THROW( wrapper.getDimLabels( -1 ), std::runtime_error ); + EXPECT_THROW( wrapper.getDimLabels( numDims ), std::runtime_error ); + + stdVector< string > const invalidComponentNames( wrapper.numArrayComp() + 1, "invalid" ); + conduit::Node invalidFields; + conduit::Node invalidMCArray; + EXPECT_THROW( wrapper.addBlueprintField( invalidFields, + wrapper.getName(), + "topology", + invalidComponentNames ), + std::runtime_error ); + EXPECT_THROW( wrapper.populateMCArray( invalidMCArray, invalidComponentNames ), std::runtime_error ); + + wrapper.resize( 0 ); + conduit::Node emptyFields; + conduit::Node emptyMCArray; + EXPECT_THROW( wrapper.addBlueprintField( emptyFields, wrapper.getName(), "topology" ), std::runtime_error ); + EXPECT_THROW( wrapper.populateMCArray( emptyMCArray ), std::runtime_error ); + wrapper.resize( numDims, dims.data() ); + + // Restrict dispatch to the production type catalog so the test does not introduce unrelated + // Wrapper types solely for coverage. + EXPECT_TRUE( types::dispatch( types::ListofTypeList< types::StandardArrays >{}, [&]( auto typeTuple ) + { + using ArrayType = camp::first< decltype( typeTuple ) >; + using ValueType = typename ArrayType::ValueType; + + Wrapper< ArrayType > & typedWrapper = Wrapper< ArrayType >::cast( wrapper ); + WrapperBase const & constBase = wrapper; + EXPECT_EQ( &Wrapper< ArrayType >::cast( constBase ), &typedWrapper ); + + ValueType const defaultValue = ValueType( 1 ); + typedWrapper.setDefaultValue( defaultValue ); + typedWrapper.setApplyDefaultValue( defaultValue ); + EXPECT_EQ( typedWrapper.getDefaultValue(), defaultValue ); + EXPECT_TRUE( typedWrapper.hasDefaultValue() ); + + typedWrapper.setSizedFromParent( 1 ); + typedWrapper.setRestartFlags( RestartFlags::WRITE_AND_READ ); + typedWrapper.setPlotLevel( PlotLevel::LEVEL_1 ); + typedWrapper.setInputFlag( InputFlags::OPTIONAL ); + typedWrapper.setDescription( "standard array" ); + typedWrapper.appendDescription( " wrapper" ); + typedWrapper.setRegisteringObjects( "testWrapper" ); + typedWrapper.setRTTypeName( typedWrapper.getRTTypeName() ); + typedWrapper.setName(); + + EXPECT_EQ( typedWrapper.getDescription(), "standard array wrapper" ); + EXPECT_EQ( typedWrapper.getRegisteringObjects().count( "testWrapper" ), 1 ); + EXPECT_EQ( typedWrapper.referenceAsView().size(), typedWrapper.reference().size() ); + + ArrayType & array = typedWrapper.reference(); + for( localIndex valueIndex = 0; valueIndex < array.size(); ++valueIndex ) + { + array.data()[valueIndex] = static_cast< ValueType >( valueIndex + 2 ); + } + }, wrapper ) ); + + EXPECT_FALSE( wrapper.getDefaultValueString().empty() ); + static_cast< void >( wrapper.getTypeRegex() ); + EXPECT_TRUE( wrapper.isPackable( false ) ); + static_cast< void >( wrapper.isPackable( true ) ); + + HistoryMetadata const history = wrapper.getHistoryMetadata( 1 ); + EXPECT_EQ( history.getName(), wrapper.getName() ); + EXPECT_GT( history.getRank(), 0 ); + EXPECT_GT( history.size(), 0 ); + + conduit::Node fields; + wrapper.addBlueprintField( fields, wrapper.getName(), "topology" ); + EXPECT_GT( fields.number_of_children(), 0 ); + + conduit::Node mcArray; + wrapper.populateMCArray( mcArray ); + EXPECT_GT( mcArray.number_of_children(), 0 ); + + std::unique_ptr< WrapperBase > average = + wrapper.averageOverSecondDim( wrapper.getName() + "Average", group ); + ASSERT_NE( average, nullptr ); + EXPECT_GT( average->size(), 0 ); + + std::unique_ptr< WrapperBase > clone = wrapper.clone( wrapper.getName() + "Clone", group ); + ASSERT_NE( clone, nullptr ); + EXPECT_TRUE( clone->getTypeId() == wrapper.getTypeId() ); + EXPECT_EQ( clone->voidPointer(), wrapper.voidPointer() ); + EXPECT_EQ( clone->bytesAllocated(), 0 ); + EXPECT_THROW( clone->copyWrapper( wrapper ), std::runtime_error ); + + for( bool const withMetadata : { false, true } ) + { + buffer_unit_type * unusedBuffer = nullptr; + localIndex const packedSize = wrapper.pack< false >( unusedBuffer, withMetadata, false, events ); + ASSERT_GT( packedSize, 0 ); + + buffer_type packed( packedSize ); + buffer_unit_type * packBuffer = packed.data(); + EXPECT_EQ( wrapper.pack< true >( packBuffer, withMetadata, false, events ), packedSize ); + EXPECT_EQ( packBuffer, packed.data() + packedSize ); + + if( withMetadata ) + { + buffer_unit_type const * mismatchedBuffer = packed.data(); + EXPECT_THROW( clone->unpack( mismatchedBuffer, true, false, events ), std::runtime_error ); + } + + EXPECT_TRUE( types::dispatch( types::ListofTypeList< types::StandardArrays >{}, [&]( auto typeTuple ) + { + using ArrayType = camp::first< decltype( typeTuple ) >; + using ValueType = typename ArrayType::ValueType; + ArrayType & array = Wrapper< ArrayType >::cast( wrapper ).reference(); + for( localIndex valueIndex = 0; valueIndex < array.size(); ++valueIndex ) + { + array.data()[valueIndex] = ValueType( 0 ); + } + }, wrapper ) ); + + buffer_unit_type const * unpackBuffer = packed.data(); + EXPECT_EQ( wrapper.unpack( unpackBuffer, withMetadata, false, events ), packedSize ); + EXPECT_EQ( unpackBuffer, packed.data() + packedSize ); + + EXPECT_TRUE( types::dispatch( types::ListofTypeList< types::StandardArrays >{}, [&]( auto typeTuple ) + { + using ArrayType = camp::first< decltype( typeTuple ) >; + using ValueType = typename ArrayType::ValueType; + ArrayType const & array = Wrapper< ArrayType >::cast( wrapper ).reference(); + for( localIndex valueIndex = 0; valueIndex < array.size(); ++valueIndex ) + { + EXPECT_EQ( array.data()[valueIndex], static_cast< ValueType >( valueIndex + 2 ) ); + } + }, wrapper ) ); + + unusedBuffer = nullptr; + localIndex const indexedPackedSize = + wrapper.packByIndex< false >( unusedBuffer, packIndices.toViewConst(), withMetadata, false, events ); + ASSERT_GT( indexedPackedSize, 0 ); + + buffer_type indexedPacked( indexedPackedSize ); + packBuffer = indexedPacked.data(); + EXPECT_EQ( wrapper.packByIndex< true >( packBuffer, + packIndices.toViewConst(), + withMetadata, + false, + events ), + indexedPackedSize ); + EXPECT_EQ( packBuffer, indexedPacked.data() + indexedPackedSize ); + + if( withMetadata ) + { + buffer_unit_type const * mismatchedBuffer = indexedPacked.data(); + EXPECT_THROW( clone->unpackByIndex( mismatchedBuffer, + packIndices.toViewConst(), + true, + false, + events, + MPI_REPLACE ), + std::runtime_error ); + } + + EXPECT_TRUE( types::dispatch( types::ListofTypeList< types::StandardArrays >{}, [&]( auto typeTuple ) + { + using ArrayType = camp::first< decltype( typeTuple ) >; + using ValueType = typename ArrayType::ValueType; + ArrayType & array = Wrapper< ArrayType >::cast( wrapper ).reference(); + LvArray::forValuesInSliceWithIndices( array[0], [&]( auto & value, auto const ... ) + { + value = ValueType( 0 ); + } ); + }, wrapper ) ); + + unpackBuffer = indexedPacked.data(); + EXPECT_EQ( wrapper.unpackByIndex( unpackBuffer, + packIndices.toViewConst(), + withMetadata, + false, + events, + MPI_REPLACE ), + indexedPackedSize ); + EXPECT_EQ( unpackBuffer, indexedPacked.data() + indexedPackedSize ); + + EXPECT_TRUE( types::dispatch( types::ListofTypeList< types::StandardArrays >{}, [&]( auto typeTuple ) + { + using ArrayType = camp::first< decltype( typeTuple ) >; + using ValueType = typename ArrayType::ValueType; + ArrayType const & array = Wrapper< ArrayType >::cast( wrapper ).reference(); + for( localIndex valueIndex = 0; valueIndex < array.size(); ++valueIndex ) + { + EXPECT_EQ( array.data()[valueIndex], static_cast< ValueType >( valueIndex + 2 ) ); + } + }, wrapper ) ); + } + + wrapper.copy( 0, 1 ); + EXPECT_TRUE( types::dispatch( types::ListofTypeList< types::StandardArrays >{}, [&]( auto typeTuple ) + { + using ArrayType = camp::first< decltype( typeTuple ) >; + ArrayType const & array = Wrapper< ArrayType >::cast( wrapper ).reference(); + LvArray::forValuesInSliceWithIndices( array[0], [&]( auto const & expectedValue, auto const ... indices ) + { + EXPECT_EQ( array( 1, indices ... ), expectedValue ); + } ); + }, wrapper ) ); + wrapper.move( hostMemorySpace, false ); + wrapper.move( hostMemorySpace, true ); + + wrapper.setRestartFlags( RestartFlags::NO_WRITE ); + wrapper.registerToWrite(); + wrapper.finishWriting(); + wrapper.setRestartFlags( RestartFlags::WRITE ); + wrapper.registerToWrite(); + wrapper.finishWriting(); + EXPECT_FALSE( wrapper.loadFromConduit() ); + + stdVector< camp::idx_t > invalidMetadata( numDims + 1, 0 ); + conduit::DataType const invalidMetadataType( conduitTypeInfo< camp::idx_t >::id, + invalidMetadata.size() ); + wrapper.setRestartFlags( RestartFlags::WRITE_AND_READ ); + + wrapper.registerToWrite(); + group.getConduitNode()[wrapper.getName()]["__permutation__"].set( invalidMetadataType, + invalidMetadata.data() ); + EXPECT_THROW( wrapper.loadFromConduit(), std::runtime_error ); + + wrapper.registerToWrite(); + camp::idx_t * const permutation = + group.getConduitNode()[wrapper.getName()]["__permutation__"].value(); + permutation[0] = numDims; + EXPECT_THROW( wrapper.loadFromConduit(), std::runtime_error ); + + wrapper.registerToWrite(); + group.getConduitNode()[wrapper.getName()]["__dimensions__"].set( invalidMetadataType, + invalidMetadata.data() ); + EXPECT_THROW( wrapper.loadFromConduit(), std::runtime_error ); + + wrapper.registerToWrite(); + buffer_unit_type invalidValue = 0; + conduit::DataType const invalidValueType( conduitTypeInfo< buffer_unit_type >::id, 1 ); + group.getConduitNode()[wrapper.getName()]["__values__"].set( invalidValueType, &invalidValue ); + EXPECT_THROW( wrapper.loadFromConduit(), std::runtime_error ); + + wrapper.registerToWrite(); + wrapper.finishWriting(); + + EXPECT_THROW( wrapper.erase( std::set< localIndex >{} ), std::runtime_error ); + wrapper.erase( { 0 } ); + } ); + + EXPECT_EQ( numWrappersTested, sizeof...( ARRAY_TYPES ) ); +} + +} // namespace + template< typename T > class WrapperSetGet : public ::testing::Test { @@ -338,3 +694,8 @@ TEST_F( WrapperLimitsTest, Array2dValidateEmpty ) EXPECT_NO_THROW( w.validateLimits() ); } + +TEST( WrapperStandardArrays, VirtualInterface ) +{ + testStandardArrayVirtualInterface( types::StandardArrays{} ); +} diff --git a/src/coreComponents/fileIO/Outputs/unitTests/CMakeLists.txt b/src/coreComponents/fileIO/Outputs/unitTests/CMakeLists.txt index 04134af6c4a..30413cf24cb 100644 --- a/src/coreComponents/fileIO/Outputs/unitTests/CMakeLists.txt +++ b/src/coreComponents/fileIO/Outputs/unitTests/CMakeLists.txt @@ -3,6 +3,10 @@ set( gtest_outputs_tests testMemoryStats.cpp ) +if( ENABLE_VTK ) + list( APPEND gtest_outputs_tests testVTKPolyDataWriter.cpp ) +endif() + # Link against mainInterface to resolve basicSetup and ProblemManager symbols set( dependencyList mainInterface gtest ${parallelDeps} ) diff --git a/src/coreComponents/fileIO/Outputs/unitTests/testVTKPolyDataWriter.cpp b/src/coreComponents/fileIO/Outputs/unitTests/testVTKPolyDataWriter.cpp new file mode 100644 index 00000000000..b2af37525b3 --- /dev/null +++ b/src/coreComponents/fileIO/Outputs/unitTests/testVTKPolyDataWriter.cpp @@ -0,0 +1,337 @@ +/* + * ------------------------------------------------------------------------------------------------------------ + * SPDX-License-Identifier: LGPL-2.1-only + * + * Copyright (c) 2016-2024 Lawrence Livermore National Security LLC + * Copyright (c) 2018-2024 TotalEnergies + * Copyright (c) 2018-2024 The Board of Trustees of the Leland Stanford Junior University + * Copyright (c) 2023-2024 Chevron + * Copyright (c) 2019- GEOS/GEOSX Contributors + * All rights reserved + * + * See top level LICENSE, COPYRIGHT, CONTRIBUTORS, NOTICE, and ACKNOWLEDGEMENTS files for details. + * ------------------------------------------------------------------------------------------------------------ + */ + +#include "fileIO/vtk/VTKPolyDataWriterInterface.hpp" + +#include "mesh/CellElementRegion.hpp" +#include "mesh/NodeManager.hpp" +#include "mesh/ParticleRegion.hpp" +#include "mesh/ParticleSubRegion.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace geos; +using namespace geos::dataRepository; + +namespace +{ + +/** + * Keep this list synchronized with the layouts in types::StandardArrays. Expanding + * the operations directly in the test body avoids adding a templated test helper + * instantiation for every supported array type to coverage's function denominator. + */ +#define GEOS_FOR_EACH_REAL_STANDARD_ARRAY( OP, VALUE_TYPE ) \ + OP( VALUE_TYPE, 1, RAJA::PERM_I ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_IJ ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_JI ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_IJK ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_IKJ ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_JIK ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_JKI ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_KIJ ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_KJI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IJKL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IJLK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IKJL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IKLJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_ILJK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_ILKJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JIKL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JILK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JKIL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JKLI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JLIK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JLKI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KIJL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KILJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KJIL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KJLI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KLIJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KLJI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LIJK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LIKJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LJIK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LJKI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LKIJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LKJI ) + +#define GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, VALUE_TYPE ) \ + OP( VALUE_TYPE, 1, RAJA::PERM_I ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_IJ ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_JI ) + +#define GEOS_FOR_EACH_STANDARD_ARRAY( OP ) \ + GEOS_FOR_EACH_REAL_STANDARD_ARRAY( OP, real32 ) \ + GEOS_FOR_EACH_REAL_STANDARD_ARRAY( OP, real64 ) \ + GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, integer ) \ + GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, localIndex ) \ + GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, globalIndex ) + +class TestVTKWriter final : public geos::vtk::VTKPolyDataWriterInterface +{ + using Base = geos::vtk::VTKPolyDataWriterInterface; + +public: + TestVTKWriter() + : Base( "unused" ) + { + setOnlyPlotSpecifiedFieldNamesFlag( 1 ); + } + + using Base::writeElementFields; + using Base::writeNodeFields; + using Base::writeParticleFields; +}; + +struct FieldExpectation +{ + string name; + integer numDims; + localIndex numComponents; + bool labeled; + real64 nodeValue; + real64 elementValue; + real64 particleValue; +}; + +void checkField( vtkDataSetAttributes * const attributes, + FieldExpectation const & expected, + stdVector< real64 > const & expectedTupleValues ) +{ + vtkDataArray * const data = attributes->GetArray( expected.name.c_str() ); + ASSERT_NE( data, nullptr ); + ASSERT_EQ( data->GetNumberOfComponents(), expected.numComponents ); + ASSERT_EQ( data->GetNumberOfTuples(), expectedTupleValues.size() ); + + for( vtkIdType tuple = 0; tuple < data->GetNumberOfTuples(); ++tuple ) + { + for( localIndex component = 0; component < expected.numComponents; ++component ) + { + EXPECT_DOUBLE_EQ( data->GetComponent( tuple, component ), expectedTupleValues[tuple] ); + } + } + + if( expected.numDims == 1 ) + { + EXPECT_EQ( data->GetComponentName( 0 ), nullptr ); + return; + } + + if( expected.numDims == 2 && !expected.labeled ) + { + for( localIndex component = 0; component < expected.numComponents; ++component ) + { + EXPECT_EQ( data->GetComponentName( component ), nullptr ); + } + return; + } + + std::set< string > expectedNames; + for( localIndex component = 0; component < expected.numComponents; ++component ) + { + string componentName; + for( integer dim = 1; dim < expected.numDims; ++dim ) + { + if( dim > 1 ) + { + componentName += "/"; + } + bool const high = ( component & ( 1 << ( dim - 1 ) ) ) != 0; + componentName += expected.labeled ? ( high ? "high" : "low" ) : ( high ? "1" : "0" ); + } + expectedNames.insert( std::move( componentName ) ); + } + + std::set< string > actualNames; + for( localIndex component = 0; component < expected.numComponents; ++component ) + { + char const * const componentName = data->GetComponentName( component ); + ASSERT_NE( componentName, nullptr ); + actualNames.insert( componentName ); + } + EXPECT_EQ( actualNames, expectedNames ); +} + +TEST( VTKPolyDataWriter, StandardArrayFieldsAndMetadata ) +{ + conduit::Node rootNode; + Group root( "root", rootNode ); + + NodeManager nodeManager( "nodes", &root ); + nodeManager.resize( 4 ); + array1d< localIndex > nodeIndices( 3 ); + nodeIndices[0] = 2; + nodeIndices[1] = 0; + nodeIndices[2] = 3; + + CellElementRegion elementRegion( "elements", &root ); + CellElementSubRegion & elementSubRegion0 = + elementRegion.createElementSubRegion< CellElementSubRegion >( "elementSubRegion0" ); + CellElementSubRegion & elementSubRegion1 = + elementRegion.createElementSubRegion< CellElementSubRegion >( "elementSubRegion1" ); + elementSubRegion0.resize( 2 ); + elementSubRegion1.resize( 1 ); + std::array< ObjectManagerBase *, 2 > const elementSubRegions = + { { &elementSubRegion0, &elementSubRegion1 } }; + + ParticleRegion particleRegion( "particles", &root ); + Group & particleSubRegionGroup = + particleRegion.getGroup( ParticleRegionBase::viewKeyStruct::particleSubRegions() ); + ParticleSubRegion & particleSubRegion0 = + particleSubRegionGroup.registerGroup< ParticleSubRegion >( "particleSubRegion0" ); + ParticleSubRegion & particleSubRegion1 = + particleSubRegionGroup.registerGroup< ParticleSubRegion >( "particleSubRegion1" ); + particleSubRegion0.resize( 1 ); + particleSubRegion1.resize( 2 ); + std::array< ObjectManagerBase *, 2 > const particleSubRegions = + { { &particleSubRegion0, &particleSubRegion1 } }; + + stdVector< string > const componentLabels{ "low", "high" }; + string_array fieldNames; + stdVector< FieldExpectation > expectations; + localIndex fieldIndex = 0; + + #define GEOS_REGISTER_VTK_FIELD( VALUE_TYPE, NDIM, PERMUTATION ) \ + do \ + { \ + using ArrayType = Array< VALUE_TYPE, NDIM, PERMUTATION >; \ + string const fieldName = "standardArray_" + std::to_string( fieldIndex ); \ + bool const labeled = fieldIndex % 2 == 0; \ + real64 const nodeValue = 100 + fieldIndex; \ + real64 const elementValue = 1000 + 10 * fieldIndex; \ + real64 const particleValue = 2000 + 10 * fieldIndex; \ + fieldNames.emplace_back( fieldName ); \ + expectations.push_back( { fieldName, NDIM, 1 << ( NDIM - 1 ), labeled, \ + nodeValue, elementValue, particleValue } ); \ + \ + Wrapper< ArrayType > & nodeWrapper = nodeManager.registerWrapper< ArrayType >( fieldName ); \ + std::array< localIndex, NDIM > nodeDims; \ + nodeDims.fill( 2 ); \ + nodeDims[0] = nodeManager.size(); \ + nodeWrapper.reference().resize( NDIM, nodeDims.data() ); \ + std::fill( nodeWrapper.reference().data(), \ + nodeWrapper.reference().data() + nodeWrapper.reference().size(), \ + static_cast< VALUE_TYPE >( nodeValue ) ); \ + if( labeled ) \ + { \ + for( integer dim = 1; dim < NDIM; ++dim ) \ + { \ + nodeWrapper.setDimLabels( dim, { componentLabels.begin(), componentLabels.end() } ); \ + } \ + } \ + \ + for( std::size_t subRegionIndex = 0; subRegionIndex < elementSubRegions.size(); \ + ++subRegionIndex ) \ + { \ + ObjectManagerBase & subRegion = *elementSubRegions[subRegionIndex]; \ + Wrapper< ArrayType > & wrapper = subRegion.registerWrapper< ArrayType >( fieldName ); \ + std::array< localIndex, NDIM > dims; \ + dims.fill( 2 ); \ + dims[0] = subRegion.size(); \ + wrapper.reference().resize( NDIM, dims.data() ); \ + std::fill( wrapper.reference().data(), \ + wrapper.reference().data() + wrapper.reference().size(), \ + static_cast< VALUE_TYPE >( elementValue + subRegionIndex ) ); \ + if( labeled ) \ + { \ + for( integer dim = 1; dim < NDIM; ++dim ) \ + { \ + wrapper.setDimLabels( dim, { componentLabels.begin(), componentLabels.end() } ); \ + } \ + } \ + } \ + \ + for( std::size_t subRegionIndex = 0; subRegionIndex < particleSubRegions.size(); \ + ++subRegionIndex ) \ + { \ + ObjectManagerBase & subRegion = *particleSubRegions[subRegionIndex]; \ + Wrapper< ArrayType > & wrapper = subRegion.registerWrapper< ArrayType >( fieldName ); \ + std::array< localIndex, NDIM > dims; \ + dims.fill( 2 ); \ + dims[0] = subRegion.size(); \ + wrapper.reference().resize( NDIM, dims.data() ); \ + std::fill( wrapper.reference().data(), \ + wrapper.reference().data() + wrapper.reference().size(), \ + static_cast< VALUE_TYPE >( particleValue + subRegionIndex ) ); \ + if( labeled ) \ + { \ + for( integer dim = 1; dim < NDIM; ++dim ) \ + { \ + wrapper.setDimLabels( dim, { componentLabels.begin(), componentLabels.end() } ); \ + } \ + } \ + } \ + ++fieldIndex; \ + } while( false ); + + GEOS_FOR_EACH_STANDARD_ARRAY( GEOS_REGISTER_VTK_FIELD ) +#undef GEOS_REGISTER_VTK_FIELD + + ASSERT_EQ( fieldIndex, 75 ); + + string const indexedFieldName = "indexedNodeField"; + array1d< real64 > & indexedField = + nodeManager.registerWrapper< array1d< real64 > >( indexedFieldName ).reference(); + indexedField.resize( nodeManager.size() ); + for( localIndex node = 0; node < nodeManager.size(); ++node ) + { + indexedField[node] = 10 + node; + } + fieldNames.emplace_back( indexedFieldName ); + + TestVTKWriter writer; + writer.setFieldNames( fieldNames ); + + vtkNew< vtkUnstructuredGrid > nodeOutput; + writer.writeNodeFields( nodeManager, nodeIndices.toViewConst(), nodeOutput->GetPointData() ); + + vtkNew< vtkUnstructuredGrid > elementOutput; + writer.writeElementFields( elementRegion, elementOutput->GetCellData() ); + + vtkNew< vtkUnstructuredGrid > particleOutput; + writer.writeParticleFields( particleRegion, particleOutput->GetCellData() ); + + for( FieldExpectation const & expected : expectations ) + { + SCOPED_TRACE( expected.name ); + checkField( nodeOutput->GetPointData(), expected, + { expected.nodeValue, expected.nodeValue, expected.nodeValue } ); + checkField( elementOutput->GetCellData(), expected, + { expected.elementValue, expected.elementValue, expected.elementValue + 1 } ); + checkField( particleOutput->GetCellData(), expected, + { expected.particleValue, expected.particleValue + 1, expected.particleValue + 1 } ); + } + + FieldExpectation const indexedExpectation{ indexedFieldName, 1, 1, false, 0, 0, 0 }; + checkField( nodeOutput->GetPointData(), indexedExpectation, { 12, 10, 13 } ); +} + +#undef GEOS_FOR_EACH_STANDARD_ARRAY +#undef GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY +#undef GEOS_FOR_EACH_REAL_STANDARD_ARRAY + +} // namespace diff --git a/src/coreComponents/integrationTests/CMakeLists.txt b/src/coreComponents/integrationTests/CMakeLists.txt index 69edfa9b91b..45280829398 100644 --- a/src/coreComponents/integrationTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/CMakeLists.txt @@ -32,3 +32,7 @@ endif() add_subdirectory( wellsTests ) add_subdirectory( wavePropagationTests ) add_subdirectory( mechanicTest ) + +if( ENABLE_COVERAGE OR GEOS_ENABLE_LLVM_SOURCE_COVERAGE ) + add_subdirectory( coverageSmokeTests ) +endif() diff --git a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt new file mode 100644 index 00000000000..6c1614f9882 --- /dev/null +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -0,0 +1,339 @@ +# SPDX-License-Identifier: LGPL-2.1-only +# +# Copyright (c) 2016-2024 Lawrence Livermore National Security LLC +# Copyright (c) 2018-2024 TotalEnergies +# Copyright (c) 2018-2024 The Board of Trustees of the Leland Stanford Junior University +# Copyright (c) 2023-2024 Chevron +# Copyright (c) 2019- GEOS/GEOSX Contributors +# All rights reserved. +# +# See top level LICENSE, COPYRIGHT, CONTRIBUTORS, NOTICE, and ACKNOWLEDGEMENTS files for details. +# +#-------------------------------------------------------------------------------------------------- + +set( coverage_smoke_inputs + materialPointMethod/singleParticle/mpm_singleParticle.xml + materialPointMethod/particleRepartition/mpm_particleRepartition.xml + materialPointMethod/dfgMovingGrid/mpm_dfgMovingGrid.xml + lagrangianContactMechanics/LagrangeContactBubbleStab_FixedSlip_smoke.xml + lagrangianContactMechanics/ContactMechanics_SimpleCubes_smoke.xml + hydraulicFracturing/kgdViscosityDominated_smoke.xml + hydraulicFracturing/kgdViscosityDominated_poroelastic_smoke.xml + proppant/FlowProppantTransport2d_smoke.xml + proppant/FlowProppantBedTransport2d_smoke.xml + singlePhaseReactiveTransport/mixedReactionCarbonateSystemPorosityUpdateThermal_1D_smoke.xml + phaseField/PhaseFieldFracture_Nucleation_smoke.xml + phaseField/PhaseFieldPoromechanics_Nucleation_smoke.xml + inducedSeismicity/SeismicityRate_analytical_verification_smoke.xml + inducedSeismicity/SpringSliderExplicit_A_smoke.xml + inducedSeismicity/SpringSliderImplicit_A_smoke.xml + efemFractureMechanics/EmbFrac_Compression_CoulombFriction_smoke.xml + poromechanics/PoroDelftEggWellbore_smoke.xml + poromechanics/PoroViscoDruckerPrager_smoke.xml + poromechanicsFractures/Contact/LagrangianMultipliers/PoroElastic_conformingFracture_2d_faultSlip_smoke.xml + poromechanicsFractures/EDFM/PoroElastic_efem-edfm_verticalFrac_smoke.xml + thermoPoromechanics/ThermoPoroDruckerPrager_consolidation_smoke.xml + thermoPoromechanicsFractures/ThermoPoroElastic_conforming_smoke.xml + simplePDE/10x10x10Hex_LaplaceVEM_smoke.xml + solidMechanics/elasticHollowCylinder_orthotropic_smoke.xml + wellbore/CasedThermoElasticWellbore_ImperfectInterfaces_smoke.xml + compositionalMultiphaseFlow/2ph_cap_1d_ihu.xml + compositionalMultiphaseFlow/soreideWhitson/lockExchange/lockExchange_Z.xml + poromechanics/PoroElastic_staircase_co2_3d_fim_stab.xml + compositionalMultiphaseWell/staircase_co2_wells_3d.xml + constitutiveDriver/testPVT.xml + constitutiveDriver/testRelperm_Table_2_phase.xml + constitutiveDriver/testTriaxial_druckerPragerExtended.xml + thermalMultiphaseFlow/co2_thermal_2d.xml + compositionalMultiphaseFlow/4comp_2ph_1d.xml + wavePropagation/acous3D_pml_smoke.xml + multipleMeshBodies/testMultipleBodies.xml + singlePhaseWell/compressible_single_phase_wells_1d.xml + simplePDE/10x10x10Hex_LaplaceFEM_smoke.xml + poromechanics/PoroElastic_staircase_singlephase_3d_fim_stab.xml + phaseField/PhaseFieldFracture_CohesiveModel.xml + singlePhaseFlow/incompressible_pebi3d.xml + meshGeneration/multiBodyMeshGen_smoke.xml + phaseField/PhaseFieldFracture_DamageOnly.xml + compositionalMultiphaseFlow/deadoil_3ph_staircase_obl_3d.xml + constitutiveDriver/testTriaxial_delftEggCase1.xml + constitutiveDriver/testRelperm_VanGenuchten.xml + singlePhaseReactiveTransport/mixedReactionCarbonateSystem_1D_smoke.xml + wavePropagation/acouselas3D_Q2_abc_smoke.xml + compositionalMultiphaseWell/resvol_constraint.xml + compositionalMultiphaseWell/isothm_mass_inj_table.xml + singlePhaseFlow/PressurePerm_1d_smoke.xml + solidMechanics/elasticHollowCylinder_transverseIsotropic_smoke.xml + constitutiveDriver/testRelperm_TableHyst2ph.xml + thermalMultiphaseFlow/co2_thermal_obl_3d.xml + compositionalMultiphaseFlow/c1-ppu/grav_seg_c1ppu_hyst.xml + singlePhaseWell/perf_status_test.xml + solidMechanics/sedov_finiteStrain_smoke.xml + compositionalMultiphaseWell/benchmarks/Class09Pb3/class09_pb3_smoke_3d.xml + compositionalMultiphaseWell/benchmarks/Egg/deadOilEgg_smoke_3d.xml + wavePropagation/acous3D_vti_smoke.xml + compositionalMultiphaseFlow/co2_flux_dirichlet.xml + compositionalMultiphaseFlow/dbc/grav_seg_1d/grav_seg_1d_DBC.xml + multiphaseFlowFractures/deadOil_fractureMatrixFlow_edfm_horizontalFrac_smoke.xml + compositionalMultiphaseFlow/co2_flux_3d.xml + solidMechanics/sedov_ssle_smoke.xml + singlePhaseFlow/FieldCaseTutorial3_Thermal_smoke.xml + constitutiveDriver/testTriaxial_elasticIsotropicPressureDependent.xml + singlePhaseWell/staircase_single_phase_wells_3d.xml + compositionalMultiphaseWell/staircase_co2_wells_hybrid_3d.xml + thermoPoromechanics/ThermoPoroElastic_staircase_co2_smoke.xml + thermoPoromechanicsFractures/ThermoPoroElastic_efem-edfm_verticalFrac_smoke.xml + poromechanicsFractures/Contact/AugmentedLagrangianMultipliers/ALM_inclinedFault_ISG_smoke.xml + efemFractureMechanics/Sneddon_embeddedFrac_staticCondensation_smoke.xml + thermalSinglePhaseFlowFractures/fractureMatrixThermalFlow_edfm_smoke.xml + phaseField/PhaseFieldFracture_VolDevSplit.xml + inducedSeismicity/SeismicityRate_poromechanics_smoke.xml + constitutiveDriver/testPVT_CO2BrineTables.xml + singlePhaseFlow/sourceFlux_1d.xml + singlePhaseFlowFractures/fractureFlow_conforming_2d.xml + simplePDE/hybridHexPrism_LaplaceVEM_smoke.xml + singlePhaseFlow/vtk/3D_10x10x10_compressible_hex_gravity_smoke.xml + singlePhaseFlow/vtk/3D_10x10x10_compressible_tetra_gravity_smoke.xml + singlePhaseFlowFractures/fractureMatrixFlow_edfm_horizontalFrac_smoke.xml + solidMechanics/gravity.xml + lagrangianContactMechanics/ContactMechanics_PEBICrack_smoke.xml + singlePhaseFlow/FieldCaseTutorial3_Isothermal_smoke.xml + lagrangianContactMechanics/ContactMechanics_UnstructuredCrack_smoke.xml + lagrangianContactMechanics/ContactMechanics_Sneddon_smoke.xml + lagrangianContactMechanics/ContactMechanics_TFrac_smoke.xml + lagrangianContactMechanics/ContactMechanics_SingleFracCompression_smoke.xml + lagrangianContactMechanics/ContactMechanics_PassingCrack_smoke.xml + lagrangianContactMechanics/ALM_UnstructuredCrack_smoke.xml + lagrangianContactMechanics/ALM_Sneddon_smoke.xml + lagrangianContactMechanics/ALM_TFrac_smoke.xml + lagrangianContactMechanics/ALM_SingleFracCompression_smoke.xml + lagrangianContactMechanics/ALM_PassingCrack_smoke.xml ) + +function( add_coverage_smoke_test ) + set( options ) + set( one_value_args NAME MPI_RANKS SCHEMA TIMEOUT ) + set( multi_value_args INPUTS GEOS_ARGS ) + cmake_parse_arguments( arg + "${options}" + "${one_value_args}" + "${multi_value_args}" + ${ARGN} ) + + if( arg_UNPARSED_ARGUMENTS ) + message( FATAL_ERROR "Unknown arguments for coverage smoke test: ${arg_UNPARSED_ARGUMENTS}" ) + endif() + if( NOT arg_NAME ) + message( FATAL_ERROR "Coverage smoke test requires NAME" ) + endif() + if( arg_INPUTS AND arg_SCHEMA ) + message( FATAL_ERROR "Coverage smoke test ${arg_NAME} cannot use both INPUTS and SCHEMA" ) + endif() + if( NOT arg_INPUTS AND NOT arg_SCHEMA ) + message( FATAL_ERROR "Coverage smoke test ${arg_NAME} requires INPUTS or SCHEMA" ) + endif() + + set( ctest_name "coverageSmoke_${arg_NAME}" ) + if( TEST "${ctest_name}" ) + message( FATAL_ERROR "Duplicate coverage smoke test name: ${ctest_name}" ) + endif() + + set( input_arguments ) + foreach( coverage_smoke_input IN LISTS arg_INPUTS ) + set( input_path "${PROJECT_SOURCE_DIR}/../inputFiles/${coverage_smoke_input}" ) + if( NOT EXISTS "${input_path}" ) + message( FATAL_ERROR "Coverage smoke input does not exist: ${input_path}" ) + endif() + list( APPEND input_arguments -i "${input_path}" ) + endforeach() + + set( output_directory "${CMAKE_CURRENT_BINARY_DIR}/${arg_NAME}" ) + file( MAKE_DIRECTORY "${output_directory}" ) + + set( test_command "$" ) + if( arg_MPI_RANKS ) + if( NOT MPIEXEC_EXECUTABLE ) + message( FATAL_ERROR "Coverage smoke test ${arg_NAME} requires MPI" ) + endif() + set( test_command + "${MPIEXEC_EXECUTABLE}" + "${MPIEXEC_NUMPROC_FLAG}" + "${arg_MPI_RANKS}" + ${MPIEXEC_PREFLAGS} + "$" + ${MPIEXEC_POSTFLAGS} ) + endif() + + if( arg_SCHEMA ) + set( run_arguments -s "${output_directory}/${arg_SCHEMA}" ${arg_GEOS_ARGS} ) + else() + set( run_arguments ${input_arguments} ${arg_GEOS_ARGS} -o "${output_directory}" ) + endif() + + add_test( NAME "${ctest_name}" + COMMAND ${test_command} ${run_arguments} ) + + if( NOT arg_TIMEOUT ) + set( arg_TIMEOUT 180 ) + endif() + set_tests_properties( "${ctest_name}" + PROPERTIES + ENVIRONMENT "OMP_NUM_THREADS=1;OPENBLAS_NUM_THREADS=1" + LABELS coverage_smoke + TIMEOUT "${arg_TIMEOUT}" + WORKING_DIRECTORY "${output_directory}" ) + if( arg_MPI_RANKS ) + set_tests_properties( "${ctest_name}" + PROPERTIES + PROCESSORS "${arg_MPI_RANKS}" ) + elseif( GEOS_ENABLE_LLVM_SOURCE_COVERAGE ) + # Each instrumented process flushes every component DSO profile at exit + # (about 70 MiB per serial smoke). Account for that I/O weight so a + # --parallel 12 run does not issue twelve simultaneous profile flushes. + set_tests_properties( "${ctest_name}" + PROPERTIES + PROCESSORS 4 ) + endif() +endfunction() + +foreach( coverage_smoke_input IN LISTS coverage_smoke_inputs ) + get_filename_component( test_name "${coverage_smoke_input}" NAME_WE ) + set( coverage_smoke_overlays ) + + if( test_name STREQUAL "ALM_inclinedFault_ISG_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/ALM_inclinedFault_small_mesh.xml ) + elseif( test_name STREQUAL "staircase_co2_wells_hybrid_3d" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/staircase_co2_wells_hybrid_small_mesh.xml ) + elseif( test_name STREQUAL "acouselas3D_Q2_abc_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/acouselas3D_Q2_small_mesh.xml ) + elseif( test_name STREQUAL "ALM_TFrac_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/TFrac_small_mesh.xml ) + elseif( test_name STREQUAL "ThermoPoroElastic_staircase_co2_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/ThermoPoroElastic_staircase_small_mesh.xml ) + elseif( test_name STREQUAL "PoroElastic_staircase_co2_3d_fim_stab" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/PoroElastic_staircase_fim_small_mesh.xml ) + elseif( test_name STREQUAL "ThermoPoroDruckerPrager_consolidation_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/ThermoPoroDruckerPrager_fast_events.xml ) + elseif( test_name STREQUAL "LagrangeContactBubbleStab_FixedSlip_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/LagrangeContactBubbleStab_small_mesh.xml ) + elseif( test_name STREQUAL "PhaseFieldFracture_Nucleation_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/PhaseFieldFracture_Nucleation_fast_events.xml ) + elseif( test_name STREQUAL "FieldCaseTutorial3_Thermal_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/FieldCaseTutorial3_Thermal_small_mesh.xml ) + elseif( test_name STREQUAL "FieldCaseTutorial3_Isothermal_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/FieldCaseTutorial3_Isothermal_small_mesh.xml ) + elseif( test_name STREQUAL "SeismicityRate_poromechanics_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/SeismicityRate_small_mesh.xml ) + elseif( test_name STREQUAL "co2_thermal_obl_3d" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/co2_thermal_obl_small_mesh.xml ) + elseif( test_name STREQUAL "deadOil_fractureMatrixFlow_edfm_horizontalFrac_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/deadOil_edfm_small_mesh.xml ) + elseif( test_name STREQUAL "SpringSliderImplicit_A_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/SpringSliderImplicit_fast_events.xml ) + elseif( test_name STREQUAL "staircase_co2_wells_3d" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/staircase_co2_wells_small_mesh.xml ) + elseif( test_name STREQUAL "deadOilEgg_smoke_3d" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/deadOilEgg_small_mesh.xml ) + elseif( test_name STREQUAL "ContactMechanics_SimpleCubes_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/ContactMechanics_SimpleCubes_small_mesh.xml ) + elseif( test_name STREQUAL "grav_seg_1d_DBC" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/grav_seg_1d_DBC_small_mesh.xml ) + elseif( test_name STREQUAL "co2_flux_dirichlet" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/co2_flux_dirichlet_small_mesh.xml ) + elseif( test_name STREQUAL "co2_flux_3d" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/co2_flux_3d_small_mesh.xml ) + elseif( test_name STREQUAL "kgdViscosityDominated_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/kgdViscosityDominated_small_mesh.xml ) + elseif( test_name STREQUAL "FlowProppantBedTransport2d_smoke" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/FlowProppantBedTransport2d_fast_outputs.xml ) + elseif( test_name STREQUAL "isothm_mass_inj_table" ) + list( APPEND coverage_smoke_overlays + coverageSmokes/isothm_mass_inj_table_fast_outputs.xml ) + endif() + + add_coverage_smoke_test( NAME "${test_name}" + INPUTS "${coverage_smoke_input}" ${coverage_smoke_overlays} ) + + if( GEOS_ENABLE_LLVM_SOURCE_COVERAGE AND + ( test_name STREQUAL "ContactMechanics_SimpleCubes_smoke" OR + test_name STREQUAL "LagrangeContactBubbleStab_FixedSlip_smoke" ) ) + # These nonlinear contact matrices remain sensitive to concurrent + # instrumented solves and profile flushes. + set_tests_properties( "coverageSmoke_${test_name}" + PROPERTIES + PROCESSORS 12 ) + endif() +endforeach() + +# Higher-order DG tetrahedra have very high template multiplicity. Initialize order 3 separately +# so the order-2 timestep and order-3 setup form a bounded function-coverage milestone. +add_coverage_smoke_test( + NAME acous3D_dg_order2 + INPUTS wavePropagation/acous3D_dg_order2_coverage.xml ) + +add_coverage_smoke_test( + NAME acous3D_dg_order3_setup + INPUTS wavePropagation/acous3D_dg_order2_coverage.xml + wavePropagation/acous3D_dg_order3_overlay.xml + wavePropagation/acous3D_dg_setup_only_overlay.xml ) + +# One order-3 timestep atomically exercises the generated mass, stiffness, and surface kernels. +add_coverage_smoke_test( + NAME acous3D_dg_order3 + INPUTS wavePropagation/acous3D_dg_order2_coverage.xml + wavePropagation/acous3D_dg_order3_overlay.xml ) + +# Schema generation expands every registered object catalog into a complete data-repository tree. +add_coverage_smoke_test( + NAME schema_catalog + SCHEMA catalog_schema.xsd ) + +# The second input is an ordered overlay that reduces each cube problem to a one-cycle smoke test. +foreach( multiscale_solver IN ITEMS singlePhaseFlow solidMechanics singlePhasePoromechanics ) + add_coverage_smoke_test( + NAME "multiscale_${multiscale_solver}_msrsb" + INPUTS "multiscalePreconditioner/${multiscale_solver}/cube_msrsb.xml" + "multiscalePreconditioner/cube_coverage_smoke.xml" ) +endforeach() + +# These decompositions cover topology migration and partition-only kernels. PROCESSORS prevents +# CTest --parallel from oversubscribing the twelve CPU slots used for the coverage build. +add_coverage_smoke_test( + NAME SurfaceGenerator_mpi_2x2x2 + MPI_RANKS 8 + INPUTS surfaceGeneration/SurfaceGenerator.xml + GEOS_ARGS -x 2 -y 2 -z 2 ) + +add_coverage_smoke_test( + NAME mpm_particleRepartition_mpi_2x2x1 + MPI_RANKS 4 + INPUTS materialPointMethod/particleRepartition/mpm_particleRepartition.xml + GEOS_ARGS -x 2 -y 2 -z 1 ) + +add_coverage_smoke_test( + NAME mpm_dfgMovingGrid_mpi_2x2x1 + MPI_RANKS 4 + INPUTS materialPointMethod/dfgMovingGrid/mpm_dfgMovingGrid.xml + GEOS_ARGS -x 2 -y 2 -z 1 ) diff --git a/src/coreComponents/integrationTests/linearAlgebraTests/CMakeLists.txt b/src/coreComponents/integrationTests/linearAlgebraTests/CMakeLists.txt index 96d7e65f993..a329752611b 100644 --- a/src/coreComponents/integrationTests/linearAlgebraTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/linearAlgebraTests/CMakeLists.txt @@ -31,3 +31,11 @@ else() COMMAND ${test_name} ) endif() endforeach() + +set( test_name testMultiscaleMeshLevelDataTransfer ) +blt_add_executable( NAME ${test_name} + SOURCES ${test_name}.cpp + OUTPUT_DIR ${TEST_OUTPUT_DIRECTORY} + DEPENDS_ON ${decoratedDependencies} ${tplDependencyList} ) +geos_add_test( NAME ${test_name} + COMMAND ${test_name} ) diff --git a/src/coreComponents/integrationTests/linearAlgebraTests/testMultiscaleMeshLevelDataTransfer.cpp b/src/coreComponents/integrationTests/linearAlgebraTests/testMultiscaleMeshLevelDataTransfer.cpp new file mode 100644 index 00000000000..91c396a651e --- /dev/null +++ b/src/coreComponents/integrationTests/linearAlgebraTests/testMultiscaleMeshLevelDataTransfer.cpp @@ -0,0 +1,337 @@ +/* + * ------------------------------------------------------------------------------------------------------------ + * SPDX-License-Identifier: LGPL-2.1-only + * + * Copyright (c) 2026 Lawrence Livermore National Security LLC + * Copyright (c) 2026 GEOS/GEOSX Contributors + * All rights reserved + * + * See top level LICENSE, COPYRIGHT, CONTRIBUTORS, NOTICE, and ACKNOWLEDGEMENTS files for details. + * ------------------------------------------------------------------------------------------------------------ + */ + +/** + * @file testMultiscaleMeshLevelDataTransfer.cpp + * @brief Tests transfer of the standard array types through multiscale mesh levels. + */ + +#include "linearAlgebra/DofManager.hpp" +#include "linearAlgebra/multiscale/mesh/MeshData.hpp" +#include "linearAlgebra/multiscale/mesh/MeshLevel.hpp" +#include "linearAlgebra/unitTests/testLinearAlgebraUtils.hpp" +#include "mainInterface/GeosxState.hpp" +#include "mainInterface/ProblemManager.hpp" +#include "mainInterface/initialization.hpp" +#include "mesh/DomainPartition.hpp" +#include "integrationTests/linearAlgebraTests/testDofManagerUtils.hpp" + +#include +#include + +namespace geos +{ +namespace testing +{ + +namespace +{ + +char const * const xmlInput = + R"xml( + + + + + + + + + )xml"; + +/** + * Keep this list synchronized with the layouts in types::StandardArrays. Expanding + * the operations directly in the test body avoids adding a templated test helper + * instantiation for every supported array type to coverage's function denominator. + */ +#define GEOS_FOR_EACH_REAL_STANDARD_ARRAY( OP, VALUE_TYPE ) \ + OP( VALUE_TYPE, 1, RAJA::PERM_I ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_IJ ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_JI ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_IJK ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_IKJ ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_JIK ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_JKI ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_KIJ ) \ + OP( VALUE_TYPE, 3, RAJA::PERM_KJI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IJKL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IJLK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IKJL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_IKLJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_ILJK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_ILKJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JIKL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JILK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JKIL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JKLI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JLIK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_JLKI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KIJL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KILJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KJIL ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KJLI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KLIJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_KLJI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LIJK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LIKJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LJIK ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LJKI ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LKIJ ) \ + OP( VALUE_TYPE, 4, RAJA::PERM_LKJI ) + +#define GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, VALUE_TYPE ) \ + OP( VALUE_TYPE, 1, RAJA::PERM_I ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_IJ ) \ + OP( VALUE_TYPE, 2, RAJA::PERM_JI ) + +#define GEOS_FOR_EACH_STANDARD_ARRAY( OP ) \ + GEOS_FOR_EACH_REAL_STANDARD_ARRAY( OP, real32 ) \ + GEOS_FOR_EACH_REAL_STANDARD_ARRAY( OP, real64 ) \ + GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, integer ) \ + GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, localIndex ) \ + GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY( OP, globalIndex ) + +class MultiscaleMeshLevelDataTransferTest : public ::testing::Test +{ +protected: + + MultiscaleMeshLevelDataTransferTest() + : m_state( std::make_unique< CommandLineOptions >() ), + m_domain( m_state.getProblemManager().getDomainPartition() ), + m_fineMesh( "fine" ), + m_coarseMesh( "coarse" ) + { + setupProblemFromXML( &m_state.getProblemManager(), xmlInput ); + + DofManager supportManager( "multiscaleTransferSupport" ); + supportManager.setDomain( m_domain ); + stdVector< DofManager::FieldSupport > const support = + { { "mesh", "Level0", { "region" } } }; + supportManager.addField( "field", + FieldLocation::Elem, + 1, + support ); + m_fineMesh.buildFineMesh( m_domain, supportManager.support( "field" ) ); + + LinearSolverParameters::Multiscale::Coarsening coarsening; + coarsening.partitionType = LinearSolverParameters::Multiscale::Coarsening::PartitionType::cartesian; + coarsening.ratio.resize( 3 ); + coarsening.ratio[0] = 2; + coarsening.ratio[1] = 2; + coarsening.ratio[2] = 2; + m_coarseMesh.buildCoarseMesh( m_fineMesh, + coarsening, + { "xneg", "xpos", "yneg", "ypos", "zneg", "zpos" } ); + } + + GeosxState m_state; + DomainPartition & m_domain; + multiscale::MeshLevel m_fineMesh; + multiscale::MeshLevel m_coarseMesh; +}; + +TEST_F( MultiscaleMeshLevelDataTransferTest, standardArraysReachOriginalMesh ) +{ + stdVector< string > fieldNames; + localIndex fieldIndex = 0; + + #define GEOS_REGISTER_TRANSFER_ARRAY( VALUE_TYPE, NDIM, PERMUTATION ) \ + do \ + { \ + using ArrayType = Array< VALUE_TYPE, NDIM, PERMUTATION >; \ + string const fieldName = "standardArray_" + std::to_string( fieldIndex ); \ + fieldNames.emplace_back( fieldName ); \ + \ + ArrayType & fineCellField = \ + m_fineMesh.cellManager().registerWrapper< ArrayType >( fieldName ).reference(); \ + std::array< localIndex, NDIM > fineCellDims; \ + fineCellDims.fill( 2 ); \ + fineCellDims[0] = m_fineMesh.cellManager().size(); \ + fineCellField.resize( NDIM, fineCellDims.data() ); \ + for( localIndex i = 0; i < fineCellField.size(); ++i ) \ + { \ + fineCellField.data()[i] = static_cast< VALUE_TYPE >( 256 + 512 * fieldIndex + i ); \ + } \ + \ + ArrayType & fineNodeField = \ + m_fineMesh.nodeManager().registerWrapper< ArrayType >( fieldName ).reference(); \ + std::array< localIndex, NDIM > fineNodeDims; \ + fineNodeDims.fill( 2 ); \ + fineNodeDims[0] = m_fineMesh.nodeManager().size(); \ + fineNodeField.resize( NDIM, fineNodeDims.data() ); \ + for( localIndex i = 0; i < fineNodeField.size(); ++i ) \ + { \ + fineNodeField.data()[i] = static_cast< VALUE_TYPE >( 32768 + 512 * fieldIndex + i ); \ + } \ + \ + ArrayType & cellField = \ + m_coarseMesh.cellManager().registerWrapper< ArrayType >( fieldName ).reference(); \ + std::array< localIndex, NDIM > cellDims; \ + cellDims.fill( 2 ); \ + cellDims[0] = m_coarseMesh.cellManager().size(); \ + cellField.resize( NDIM, cellDims.data() ); \ + for( localIndex i = 0; i < cellField.size(); ++i ) \ + { \ + cellField.data()[i] = static_cast< VALUE_TYPE >( 1024 + 512 * fieldIndex + i ); \ + } \ + \ + ArrayType & nodeField = \ + m_coarseMesh.nodeManager().registerWrapper< ArrayType >( fieldName ).reference(); \ + std::array< localIndex, NDIM > nodeDims; \ + nodeDims.fill( 2 ); \ + nodeDims[0] = m_coarseMesh.nodeManager().size(); \ + nodeField.resize( NDIM, nodeDims.data() ); \ + for( localIndex i = 0; i < nodeField.size(); ++i ) \ + { \ + nodeField.data()[i] = static_cast< VALUE_TYPE >( 65536 + 512 * fieldIndex + i ); \ + } \ + ++fieldIndex; \ + } while( false ); + + GEOS_FOR_EACH_STANDARD_ARRAY( GEOS_REGISTER_TRANSFER_ARRAY ) +#undef GEOS_REGISTER_TRANSFER_ARRAY + + ASSERT_EQ( fieldIndex, 75 ); + m_fineMesh.writeCellData( fieldNames ); + m_fineMesh.writeNodeData( fieldNames ); + m_coarseMesh.writeCellData( fieldNames ); + m_coarseMesh.writeNodeData( fieldNames ); + + geos::MeshLevel & sourceMesh = m_domain.getMeshBody( "mesh" ).getMeshLevel( "Level0" ); + ElementSubRegionBase & sourceSubRegion = sourceMesh.getElemManager().getRegion( 0 ).getSubRegion( 0 ); + + arrayView1d< localIndex const > const coarseCellIndex = + m_fineMesh.cellManager().getField< fields::multiscale::CoarseCellLocalIndex >(); + arrayView1d< localIndex const > const origRegion = + m_fineMesh.cellManager().getField< fields::multiscale::OrigElementRegion >(); + arrayView1d< localIndex const > const origSubRegion = + m_fineMesh.cellManager().getField< fields::multiscale::OrigElementSubRegion >(); + arrayView1d< localIndex const > const origCellIndex = + m_fineMesh.cellManager().getField< fields::multiscale::OrigElementIndex >(); + for( localIndex i = 0; i < m_fineMesh.cellManager().size(); ++i ) + { + ASSERT_EQ( origRegion[i], 0 ); + ASSERT_EQ( origSubRegion[i], 0 ); + } + + arrayView1d< localIndex const > const fineNodeIndex = + m_coarseMesh.nodeManager().getField< fields::multiscale::FineNodeLocalIndex >(); + arrayView1d< localIndex const > const origNodeIndex = + m_fineMesh.nodeManager().getField< fields::multiscale::OrigNodeIndex >(); + + fieldIndex = 0; + #define GEOS_VERIFY_TRANSFERRED_ARRAY( VALUE_TYPE, NDIM, PERMUTATION ) \ + do \ + { \ + using ArrayType = Array< VALUE_TYPE, NDIM, PERMUTATION >; \ + string const fieldName = "standardArray_" + std::to_string( fieldIndex ); \ + string const fineOutputName = m_fineMesh.name() + '_' + fieldName; \ + string const coarseOutputName = m_coarseMesh.name() + '_' + fieldName; \ + \ + ArrayType const & fineCellField = \ + m_fineMesh.cellManager().getReference< ArrayType >( fieldName ); \ + ArrayType const & fineOutputCellField = \ + sourceSubRegion.getReference< ArrayType >( fineOutputName ); \ + for( integer dim = 1; dim < NDIM; ++dim ) \ + { \ + EXPECT_EQ( fineOutputCellField.size( dim ), fineCellField.size( dim ) ); \ + } \ + for( localIndex i = 0; i < m_fineMesh.cellManager().size(); ++i ) \ + { \ + LvArray::forValuesInSliceWithIndices( fineCellField[i], \ + [&]( auto const & expectedValue, auto const ... indices ) \ +{ \ + EXPECT_EQ( fineOutputCellField( origCellIndex[i], indices ... ), expectedValue ); \ +} ); \ + } \ + \ + ArrayType const & coarseCellField = \ + m_coarseMesh.cellManager().getReference< ArrayType >( fieldName ); \ + ArrayType const & coarseOutputCellField = \ + sourceSubRegion.getReference< ArrayType >( coarseOutputName ); \ + for( integer dim = 1; dim < NDIM; ++dim ) \ + { \ + EXPECT_EQ( coarseOutputCellField.size( dim ), coarseCellField.size( dim ) ); \ + } \ + for( localIndex i = 0; i < m_fineMesh.cellManager().size(); ++i ) \ + { \ + LvArray::forValuesInSliceWithIndices( coarseCellField[coarseCellIndex[i]], \ + [&]( auto const & expectedValue, auto const ... indices ) \ +{ \ + EXPECT_EQ( coarseOutputCellField( origCellIndex[i], indices ... ), expectedValue ); \ +} ); \ + } \ + \ + ArrayType const & fineNodeField = \ + m_fineMesh.nodeManager().getReference< ArrayType >( fieldName ); \ + ArrayType const & fineOutputNodeField = \ + sourceMesh.getNodeManager().getReference< ArrayType >( fineOutputName ); \ + for( integer dim = 1; dim < NDIM; ++dim ) \ + { \ + EXPECT_EQ( fineOutputNodeField.size( dim ), fineNodeField.size( dim ) ); \ + } \ + for( localIndex i = 0; i < m_fineMesh.nodeManager().size(); ++i ) \ + { \ + LvArray::forValuesInSliceWithIndices( fineNodeField[i], \ + [&]( auto const & expectedValue, auto const ... indices ) \ +{ \ + EXPECT_EQ( fineOutputNodeField( origNodeIndex[i], indices ... ), expectedValue ); \ +} ); \ + } \ + \ + ArrayType const & coarseNodeField = \ + m_coarseMesh.nodeManager().getReference< ArrayType >( fieldName ); \ + ArrayType const & coarseOutputNodeField = \ + sourceMesh.getNodeManager().getReference< ArrayType >( coarseOutputName ); \ + for( integer dim = 1; dim < NDIM; ++dim ) \ + { \ + EXPECT_EQ( coarseOutputNodeField.size( dim ), coarseNodeField.size( dim ) ); \ + } \ + for( localIndex i = 0; i < m_coarseMesh.nodeManager().size(); ++i ) \ + { \ + LvArray::forValuesInSliceWithIndices( coarseNodeField[i], \ + [&]( auto const & expectedValue, auto const ... indices ) \ +{ \ + EXPECT_EQ( coarseOutputNodeField( origNodeIndex[fineNodeIndex[i]], indices ... ), \ + expectedValue ); \ +} ); \ + } \ + ++fieldIndex; \ + } while( false ); + + GEOS_FOR_EACH_STANDARD_ARRAY( GEOS_VERIFY_TRANSFERRED_ARRAY ) +#undef GEOS_VERIFY_TRANSFERRED_ARRAY + + EXPECT_EQ( fieldIndex, 75 ); +} + +#undef GEOS_FOR_EACH_STANDARD_ARRAY +#undef GEOS_FOR_EACH_INTEGRAL_STANDARD_ARRAY +#undef GEOS_FOR_EACH_REAL_STANDARD_ARRAY + +} // namespace +} // namespace testing +} // namespace geos + +int main( int argc, char * * argv ) +{ + geos::testing::LinearAlgebraTestScope scope( argc, argv ); + return RUN_ALL_TESTS(); +} diff --git a/src/coreComponents/linearAlgebra/multiscale/mesh/MeshLevel.cpp b/src/coreComponents/linearAlgebra/multiscale/mesh/MeshLevel.cpp index b45e08c7e52..aee193f27af 100644 --- a/src/coreComponents/linearAlgebra/multiscale/mesh/MeshLevel.cpp +++ b/src/coreComponents/linearAlgebra/multiscale/mesh/MeshLevel.cpp @@ -519,7 +519,7 @@ void MeshLevel::writeCellDataCoarse( stdVector< string > const & fieldNames, int dims[0] = dstField.size( 0 ); dstField.resize( ArrayType::NDIM, dims.data ); - forAll< parallelHostPolicy >( dstField.size(), [=, dstField = dstField.toView()]( localIndex const ic ) + forAll< parallelHostPolicy >( dstField.size( 0 ), [=, dstField = dstField.toView()]( localIndex const ic ) { LvArray::forValuesInSliceWithIndices( dstField[ ic ], [&]( auto & dstVal, auto const ... indices ) diff --git a/src/docs/sphinx/buildGuide/BuildProcess.rst b/src/docs/sphinx/buildGuide/BuildProcess.rst index 3fcff6c15b3..84349e83fe8 100644 --- a/src/docs/sphinx/buildGuide/BuildProcess.rst +++ b/src/docs/sphinx/buildGuide/BuildProcess.rst @@ -72,7 +72,7 @@ Option Default Explanation ``ENABLE_DOCS`` ``ON`` Build documentation (Sphinx and Doxygen) ``ENABLE_WARNINGS_AS_ERRORS`` ``ON`` Treat all warnings as errors ``ENABLE_TOTALVIEW_OUTPUT`` ``OFF`` Enables TotalView debugger custom view of GEOS data structures -``ENABLE_COV`` ``OFF`` Enables code coverage +``ENABLE_COVERAGE`` ``OFF`` Enables BLT's legacy gcov-compatible coverage backend ``GEOS_ENABLE_TESTS`` ``ON`` Enables unit testing targets ``GEOS_LA_INTERFACE`` ``Hypre`` Choiсe of Linear Algebra backend (Hypre/Petsc/Trilinos) ``GEOS_BUILD_OBJ_LIBS`` ``ON`` Use CMake Object Libraries build @@ -81,3 +81,10 @@ Option Default Explanation ``GEOS_PARALLEL_LINK_JOBS`` Max. number of link jobs (when using Ninja), in addition to ``-j`` flag ``GEOS_INSTALL_SCHEMA`` ``ON`` Enables schema generation and installation =============================== ========= ============================================================================== + +For compiler-native Clang source coverage, configure a CPU shared-library build +with ``GEOS_ENABLE_LLVM_SOURCE_COVERAGE=ON``. The option rejects BLT's legacy +``ENABLE_COVERAGE`` mode if a host configuration enables it, because the two +options select different and incompatible profile formats. The CI wrapper +exposes only the LLVM choice; ``scripts/llvm_source_branch_coverage.sh`` +generates the production report from the resulting raw profiles. diff --git a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst index b72fee6159f..e90d1f45b55 100644 --- a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst +++ b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst @@ -161,10 +161,36 @@ Unit testing is not about testing every single line of code (an unrealistic goal Code Coverage ^^^^^^^^^^^^^ -**Code coverage should never decrease.** New code contributions must maintain or improve overall code coverage. -Use Codecov to report untested code paths. - -Currently, Codecov does not cover integrated tests execution but they should be taken into account, especially for minor physical kernel changes. +New code contributions should maintain or improve overall code coverage. The +dedicated Clang/LLVM CI job runs the ``coverage_smoke`` suite and enforces the +repository-owned lines, functions, and canonical branch floors in +``.github/coverage-thresholds.json``. The initial floors are 70.01% for lines, +75.01% for functions, and 50.01% for canonical branches. Regions and the native +instantiation-weighted branch interpretation are reported for review without +being hard gates. The compiler-native report is published in the GitHub job +summary and retained as a single self-contained ``index.html`` workflow +artifact; the page puts the reviewer summary and coverage tables first, with +the intermediate JSON, logs, and source reports in collapsible diagnostic +sections. No hosted coverage service or secret is required. + +Coverage is opt-in for pull requests: add the ``ci: run code coverage`` label +to request the job. The job also runs on pushes to ``develop`` so that trusted +baseline artifacts can be retained. + +For pull requests, the same job also intersects the tested merge commit's diff +with LLVM's detailed source records. It reports coverage of changed executable +lines, new function entry sites, and native branch outcomes, then ranks the +changed locations with the largest misses and suggests the kind of case needed +to exercise each one. When an artifact exists for the exact target-branch +commit, the summary also shows the canonical project coverage change from that +base. A missing or incompatible base artifact is reported as unavailable rather +than substituted with a different commit. These PR-specific signals are +advisory; the repository coverage floors remain the enforced policy. The job's +reviewer summary clearly separates those gates from unavailable baseline +comparisons and uncovered changed lines. + +Integrated tests are not part of this focused CI coverage pass, but they should +still be taken into account, especially for minor physical kernel changes. Integrated Tests & Examples ^^^^^^^^^^^^^^^^^^^^^^^^^^^