From 5d5f933c260bdc0f23a19cf62a3dd62a2c97312a Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Mon, 10 Aug 2026 23:33:28 -0400 Subject: [PATCH 01/19] test: pass first five-point coverage milestone Register 12 coverage-only solver scenarios for MPM, contact, hydrofracture, proppant, reactive transport, and phase field. Isolate outputs for safe 12-way CTest execution.\n\nVerified CPU-local line coverage: 73.7% (127243/172646), up 8.06 points. --- .../integrationTests/CMakeLists.txt | 4 ++ .../coverageSmokeTests/CMakeLists.txt | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt diff --git a/src/coreComponents/integrationTests/CMakeLists.txt b/src/coreComponents/integrationTests/CMakeLists.txt index 69edfa9b91b..e2814918fe6 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 ) + 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..e7024ae6ac3 --- /dev/null +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -0,0 +1,48 @@ +# 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 ) + +foreach( coverage_smoke_input IN LISTS coverage_smoke_inputs ) + set( input_path "${CMAKE_SOURCE_DIR}/../inputFiles/${coverage_smoke_input}" ) + if( NOT EXISTS "${input_path}" ) + message( FATAL_ERROR "Coverage smoke input does not exist: ${input_path}" ) + endif() + + get_filename_component( test_name "${coverage_smoke_input}" NAME_WE ) + set( output_directory "${CMAKE_CURRENT_BINARY_DIR}/${test_name}" ) + file( MAKE_DIRECTORY "${output_directory}" ) + + add_test( NAME coverageSmoke_${test_name} + COMMAND "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/geosx" + -i "${input_path}" + -o "${output_directory}" ) + set_tests_properties( coverageSmoke_${test_name} + PROPERTIES + ENVIRONMENT "OMP_NUM_THREADS=1" + LABELS coverage_smoke + TIMEOUT 180 + WORKING_DIRECTORY "${output_directory}" ) +endforeach() From ba1e07094062d19efbb37382aa8b454a5820e84d Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Mon, 10 Aug 2026 23:40:28 -0400 Subject: [PATCH 02/19] test: pass second five-point coverage milestone Add 13 coverage-only scenarios for induced seismicity, embedded fractures, poromechanics, thermo-poromechanics, VEM, orthotropic mechanics, and wellbores.\n\nVerified CPU-local line coverage: 76.9285% (132814/172646), up 11.29 points from baseline. --- .../coverageSmokeTests/CMakeLists.txt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt index e7024ae6ac3..67fe418bced 100644 --- a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -23,7 +23,20 @@ set( coverage_smoke_inputs proppant/FlowProppantBedTransport2d_smoke.xml singlePhaseReactiveTransport/mixedReactionCarbonateSystemPorosityUpdateThermal_1D_smoke.xml phaseField/PhaseFieldFracture_Nucleation_smoke.xml - phaseField/PhaseFieldPoromechanics_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 ) foreach( coverage_smoke_input IN LISTS coverage_smoke_inputs ) set( input_path "${CMAKE_SOURCE_DIR}/../inputFiles/${coverage_smoke_input}" ) From 8710aab27edb5ba75c95f21d632cbfbf8d7509df Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 00:00:02 -0400 Subject: [PATCH 03/19] test: pass third five-point coverage milestone Add 18 coverage-only scenarios for specialized flow, wells, constitutive drivers, stabilized poromechanics, PML, FEM, PEBI/VTK import, multi-body output, and phase field.\n\nVerified CPU-local line coverage: 80.7102% (139343/172646), up 15.0742 points from baseline. --- .../coverageSmokeTests/CMakeLists.txt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt index 67fe418bced..0ad0a2491bf 100644 --- a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -36,7 +36,25 @@ set( coverage_smoke_inputs thermoPoromechanicsFractures/ThermoPoroElastic_conforming_smoke.xml simplePDE/10x10x10Hex_LaplaceVEM_smoke.xml solidMechanics/elasticHollowCylinder_orthotropic_smoke.xml - wellbore/CasedThermoElasticWellbore_ImperfectInterfaces_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 ) foreach( coverage_smoke_input IN LISTS coverage_smoke_inputs ) set( input_path "${CMAKE_SOURCE_DIR}/../inputFiles/${coverage_smoke_input}" ) From 6a85e1f3a242e7af70e2844f9d9c0a64c9cfac3f Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 00:31:51 -0400 Subject: [PATCH 04/19] test: reach 85 percent line coverage Register high-impact serial and MPI smoke scenarios across flow, wells, contact, mesh, output, and partitioning paths. Add compact ordered overlays for the three multiscale-preconditioner cases and account for MPI ranks in twelve-way CTest scheduling. Verified at 86.084821% lines on the local CPU coverage build. --- .../cube_coverage_smoke.xml | 27 +++ .../coverageSmokeTests/CMakeLists.txt | 164 ++++++++++++++++-- 2 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 inputFiles/multiscalePreconditioner/cube_coverage_smoke.xml 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/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt index 0ad0a2491bf..d46aa8024ef 100644 --- a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -54,26 +54,164 @@ set( coverage_smoke_inputs phaseField/PhaseFieldFracture_CohesiveModel.xml singlePhaseFlow/incompressible_pebi3d.xml meshGeneration/multiBodyMeshGen_smoke.xml - phaseField/PhaseFieldFracture_DamageOnly.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 ) -foreach( coverage_smoke_input IN LISTS coverage_smoke_inputs ) - set( input_path "${CMAKE_SOURCE_DIR}/../inputFiles/${coverage_smoke_input}" ) - if( NOT EXISTS "${input_path}" ) - message( FATAL_ERROR "Coverage smoke input does not exist: ${input_path}" ) +function( add_coverage_smoke_test ) + set( options ) + set( one_value_args NAME MPI_RANKS 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( NOT arg_INPUTS ) + message( FATAL_ERROR "Coverage smoke test ${arg_NAME} requires at least one input" ) endif() - get_filename_component( test_name "${coverage_smoke_input}" NAME_WE ) - set( output_directory "${CMAKE_CURRENT_BINARY_DIR}/${test_name}" ) + 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}" ) - add_test( NAME coverageSmoke_${test_name} - COMMAND "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/geosx" - -i "${input_path}" + 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() + + add_test( NAME "${ctest_name}" + COMMAND ${test_command} + ${input_arguments} + ${arg_GEOS_ARGS} -o "${output_directory}" ) - set_tests_properties( coverageSmoke_${test_name} + + if( NOT arg_TIMEOUT ) + set( arg_TIMEOUT 180 ) + endif() + set_tests_properties( "${ctest_name}" PROPERTIES - ENVIRONMENT "OMP_NUM_THREADS=1" + ENVIRONMENT "OMP_NUM_THREADS=1;OPENBLAS_NUM_THREADS=1" LABELS coverage_smoke - TIMEOUT 180 + TIMEOUT "${arg_TIMEOUT}" WORKING_DIRECTORY "${output_directory}" ) + if( arg_MPI_RANKS ) + set_tests_properties( "${ctest_name}" + PROPERTIES + PROCESSORS "${arg_MPI_RANKS}" ) + endif() +endfunction() + +foreach( coverage_smoke_input IN LISTS coverage_smoke_inputs ) + get_filename_component( test_name "${coverage_smoke_input}" NAME_WE ) + add_coverage_smoke_test( NAME "${test_name}" + INPUTS "${coverage_smoke_input}" ) endforeach() + +# 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 ) From 12de60249f7f9ae28aea5aba3c078acee31f9ea4 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 00:48:49 -0400 Subject: [PATCH 05/19] test: pass first function coverage milestone Exercise order-2 acoustic DG through one timestep and initialize the order-3 tetrahedral formulation with an ordered setup-only overlay. This covers 15,590 additional function instances and raises clean CPU function coverage from 35.118363% to 41.075680%. --- .../acous3D_dg_order2_coverage.xml | 81 +++++++++++++++++++ .../acous3D_dg_order3_overlay.xml | 15 ++++ .../acous3D_dg_setup_only_overlay.xml | 10 +++ .../coverageSmokeTests/CMakeLists.txt | 12 +++ 4 files changed, 118 insertions(+) create mode 100644 inputFiles/wavePropagation/acous3D_dg_order2_coverage.xml create mode 100644 inputFiles/wavePropagation/acous3D_dg_order3_overlay.xml create mode 100644 inputFiles/wavePropagation/acous3D_dg_setup_only_overlay.xml 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/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt index d46aa8024ef..a8dd9dedbdf 100644 --- a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -188,6 +188,18 @@ foreach( coverage_smoke_input IN LISTS coverage_smoke_inputs ) INPUTS "${coverage_smoke_input}" ) 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 ) + # 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( From 60a35057b623eb0490f980e37ecbb62a169ea397 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 01:01:47 -0400 Subject: [PATCH 06/19] test: cover order-3 DG function matrix --- .../coverageSmokeTests/CMakeLists.txt | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt index a8dd9dedbdf..03551ac8dbe 100644 --- a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -111,7 +111,7 @@ set( coverage_smoke_inputs function( add_coverage_smoke_test ) set( options ) - set( one_value_args NAME MPI_RANKS TIMEOUT ) + set( one_value_args NAME MPI_RANKS SCHEMA TIMEOUT ) set( multi_value_args INPUTS GEOS_ARGS ) cmake_parse_arguments( arg "${options}" @@ -125,8 +125,11 @@ function( add_coverage_smoke_test ) if( NOT arg_NAME ) message( FATAL_ERROR "Coverage smoke test requires NAME" ) endif() - if( NOT arg_INPUTS ) - message( FATAL_ERROR "Coverage smoke test ${arg_NAME} requires at least one input" ) + 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}" ) @@ -160,11 +163,14 @@ function( add_coverage_smoke_test ) ${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} - ${input_arguments} - ${arg_GEOS_ARGS} - -o "${output_directory}" ) + COMMAND ${test_command} ${run_arguments} ) if( NOT arg_TIMEOUT ) set( arg_TIMEOUT 180 ) @@ -200,6 +206,17 @@ add_coverage_smoke_test( 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( From f112946bcf72bf9b2405d10aad30ea0ade6fd284 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 02:02:20 -0400 Subject: [PATCH 07/19] test: reach 60 percent function coverage Exercise the explicit BufferOps and StandardArrays wrapper matrices, add VTK and multiscale transfer coverage, and correct coarse multidimensional cell-transfer bounds. Verified on the CPU coverage build at 172,170/267,363 functions (64.395597%) using --parallel 12. --- .../dataRepository/unitTests/testPacking.cpp | 204 +++++++++++ .../dataRepository/unitTests/testWrapper.cpp | 255 +++++++++++++ .../fileIO/Outputs/unitTests/CMakeLists.txt | 4 + .../unitTests/testVTKPolyDataWriter.cpp | 337 ++++++++++++++++++ .../linearAlgebraTests/CMakeLists.txt | 8 + .../testMultiscaleMeshLevelDataTransfer.cpp | 337 ++++++++++++++++++ .../multiscale/mesh/MeshLevel.cpp | 2 +- 7 files changed, 1146 insertions(+), 1 deletion(-) create mode 100644 src/coreComponents/fileIO/Outputs/unitTests/testVTKPolyDataWriter.cpp create mode 100644 src/coreComponents/integrationTests/linearAlgebraTests/testMultiscaleMeshLevelDataTransfer.cpp 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..8bf826426b0 100644 --- a/src/coreComponents/dataRepository/unitTests/testWrapper.cpp +++ b/src/coreComponents/dataRepository/unitTests/testWrapper.cpp @@ -14,6 +14,7 @@ */ // Source includes +#include "common/TypeDispatch.hpp" #include "dataRepository/Group.hpp" #include "dataRepository/Wrapper.hpp" @@ -21,9 +22,259 @@ #include #include +// System includes +#include + using namespace geos; using namespace dataRepository; +namespace +{ + +template< typename ... ARRAY_TYPES > +void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) +{ + // 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 ); + + 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] ); + } + + // 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 ); + + 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 ); + + 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 ); + + 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() ); + + wrapper.erase( { 0 } ); + } ); + + EXPECT_EQ( numWrappersTested, sizeof...( ARRAY_TYPES ) ); +} + +} // namespace + template< typename T > class WrapperSetGet : public ::testing::Test { @@ -337,4 +588,8 @@ TEST_F( WrapperLimitsTest, Array2dValidateEmpty ) w.setLimits( 0.0, 1.0, wrapperLimits::LimitsMode::Error ); 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/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 ) From 21775d9099d439edd62a3d2a066a2e3bdd3549be Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 03:30:10 -0400 Subject: [PATCH 08/19] test: add source branch coverage reporting --- scripts/llvm_source_branch_coverage.sh | 347 ++++++++++++++++++ src/cmake/GeosxOptions.cmake | 27 ++ .../dataRepository/unitTests/testWrapper.cpp | 105 ++++++ .../integrationTests/CMakeLists.txt | 2 +- 4 files changed, 480 insertions(+), 1 deletion(-) create mode 100755 scripts/llvm_source_branch_coverage.sh diff --git a/scripts/llvm_source_branch_coverage.sh b/scripts/llvm_source_branch_coverage.sh new file mode 100755 index 00000000000..c23eb88da79 --- /dev/null +++ b/scripts/llvm_source_branch_coverage.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-only +# +# Merge Clang source-coverage profiles and report three production branch metrics: +# +# canonical LLVM's CoverageReport summary for the compiled configuration. +# native Every LCOV source-branch outcome emitted by Clang. Template and +# inline-function instantiations can appear more than once. +# definition The same LCOV outcomes after llvm-cov combines execution counts +# for C++ function instantiations. No source condition is excluded +# based on whether it was covered. +# +# 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) + +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, native.info, +definition.info, export logs, and branch-summary.txt. The JSON and .info files +make every numerator and denominator auditable with LLVM and LCOV tooling. +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)" +rm -f -- "${output_dir}/branch-summary.txt" "${output_dir}/branch-summary.txt.tmp" + +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 + +# Escape a literal path for LLVM's regular-expression parser. Keeping the +# inclusion boundary anchored prevents similarly named sibling trees from +# entering the denominator. +source_regex="$(printf '%s' "${source_dir}" | sed 's/[][(){}.^$*+?|\\]/\\&/g')" +source_regex="^${source_regex}/" +production_exclude_regex='/(unitTests|unitTestUtilities|integrationTests|tests|examples|benchmarks|testingUtilities)/' +production_exclude_regex+='|/codingUtilities/UnitTestUtilities[.]hpp$' + +native_info="${output_dir}/native.info" +definition_info="${output_dir}/definition.info" +llvm_summary="${output_dir}/llvm-summary.json" +common_export_args=( + -instr-profile "${profdata}" + -include-filename-regex "${source_regex}" + -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 +} + +run_export "${native_info}" "${output_dir}/native-export.log" \ + -format=lcov -unify-instantiations=false +run_export "${definition_info}" "${output_dir}/definition-export.log" \ + -format=lcov -unify-instantiations=true +run_export "${llvm_summary}" "${output_dir}/summary-export.log" \ + -format=text -summary-only -unify-instantiations=false + +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 definition_covered definition_total definition_percent \ + < <(summarize_branches "${definition_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 || definition_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}" + printf 'definition-normalized LCOV branch outcomes: %d/%d (%.6f%%)\n' \ + "${definition_covered}" "${definition_total}" "${definition_percent}" + printf 'collapsed branch outcomes: %d\n' "$(( native_total - definition_total ))" +} | tee "${summary_tmp}" +mv -- "${summary_tmp}" "${summary}" diff --git a/src/cmake/GeosxOptions.cmake b/src/cmake/GeosxOptions.cmake index 353e8697c59..5166eebe29d 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/testWrapper.cpp b/src/coreComponents/dataRepository/unitTests/testWrapper.cpp index 8bf826426b0..bbb53554720 100644 --- a/src/coreComponents/dataRepository/unitTests/testWrapper.cpp +++ b/src/coreComponents/dataRepository/unitTests/testWrapper.cpp @@ -17,12 +17,15 @@ #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; @@ -31,9 +34,34 @@ 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; @@ -85,6 +113,11 @@ void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) 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 ); @@ -94,6 +127,28 @@ void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) 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 ) @@ -160,6 +215,7 @@ void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) 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 } ) { @@ -172,6 +228,12 @@ void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) 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 ) >; @@ -213,6 +275,18 @@ void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) 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 ) >; @@ -267,6 +341,37 @@ void testStandardArrayVirtualInterface( camp::list< ARRAY_TYPES ... > ) 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 } ); } ); diff --git a/src/coreComponents/integrationTests/CMakeLists.txt b/src/coreComponents/integrationTests/CMakeLists.txt index e2814918fe6..45280829398 100644 --- a/src/coreComponents/integrationTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/CMakeLists.txt @@ -33,6 +33,6 @@ add_subdirectory( wellsTests ) add_subdirectory( wavePropagationTests ) add_subdirectory( mechanicTest ) -if( ENABLE_COVERAGE ) +if( ENABLE_COVERAGE OR GEOS_ENABLE_LLVM_SOURCE_COVERAGE ) add_subdirectory( coverageSmokeTests ) endif() From bfcbd1b34c33db57b0d825780ab4154d5919a5c7 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 10:14:11 -0400 Subject: [PATCH 09/19] test: speed up coverage smoke suite --- .../ALM_inclinedFault_small_mesh.xml | 77 + ...ontactMechanics_SimpleCubes_small_mesh.xml | 66 + ...eldCaseTutorial3_Isothermal_small_mesh.xml | 9 + .../FieldCaseTutorial3_Thermal_small_mesh.xml | 9 + ...lowProppantBedTransport2d_fast_outputs.xml | 10 + .../LagrangeContactBubbleStab_small_mesh.xml | 16 + ...seFieldFracture_Nucleation_fast_events.xml | 20 + .../PoroElastic_staircase_fim_small_mesh.xml | 19 + .../SeismicityRate_small_mesh.xml | 16 + .../SpringSliderImplicit_fast_events.xml | 32 + .../coverageSmokes/TFrac_small_mesh.xml | 16 + .../ThermoPoroDruckerPrager_fast_events.xml | 88 + ...ThermoPoroElastic_staircase_small_mesh.xml | 40 + .../acouselas3D_Q2_small_mesh.xml | 16 + .../coverageSmokes/co2_flux_3d_small_mesh.xml | 16 + .../co2_flux_dirichlet_small_mesh.xml | 16 + .../co2_thermal_obl_small_mesh.xml | 16 + .../coverageSmokes/deadOilEgg_small_mesh.xml | 18 + .../deadOil_edfm_small_mesh.xml | 16 + .../coverageSmokes/fault_contact_coverage.vtu | 1495 +++++++++++++++++ .../grav_seg_1d_DBC_small_mesh.xml | 16 + .../isothm_mass_inj_table_fast_outputs.xml | 14 + .../kgdViscosityDominated_small_mesh.xml | 18 + .../staircase3d_tet_coverage.vtu | 255 +++ .../staircase_co2_wells_hybrid_small_mesh.xml | 27 + .../staircase_co2_wells_small_mesh.xml | 31 + .../synthetic_isothermal_coverage.vtu | 542 ++++++ .../synthetic_thermal_coverage.vtu | 505 ++++++ .../coverageSmokeTests/CMakeLists.txt | 95 +- 29 files changed, 3513 insertions(+), 1 deletion(-) create mode 100644 inputFiles/coverageSmokes/ALM_inclinedFault_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/ContactMechanics_SimpleCubes_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/FieldCaseTutorial3_Isothermal_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/FieldCaseTutorial3_Thermal_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/FlowProppantBedTransport2d_fast_outputs.xml create mode 100644 inputFiles/coverageSmokes/LagrangeContactBubbleStab_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/PhaseFieldFracture_Nucleation_fast_events.xml create mode 100644 inputFiles/coverageSmokes/PoroElastic_staircase_fim_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/SeismicityRate_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/SpringSliderImplicit_fast_events.xml create mode 100644 inputFiles/coverageSmokes/TFrac_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/ThermoPoroDruckerPrager_fast_events.xml create mode 100644 inputFiles/coverageSmokes/ThermoPoroElastic_staircase_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/acouselas3D_Q2_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/co2_flux_3d_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/co2_flux_dirichlet_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/co2_thermal_obl_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/deadOilEgg_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/deadOil_edfm_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/fault_contact_coverage.vtu create mode 100644 inputFiles/coverageSmokes/grav_seg_1d_DBC_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/isothm_mass_inj_table_fast_outputs.xml create mode 100644 inputFiles/coverageSmokes/kgdViscosityDominated_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/staircase3d_tet_coverage.vtu create mode 100644 inputFiles/coverageSmokes/staircase_co2_wells_hybrid_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/staircase_co2_wells_small_mesh.xml create mode 100644 inputFiles/coverageSmokes/synthetic_isothermal_coverage.vtu create mode 100644 inputFiles/coverageSmokes/synthetic_thermal_coverage.vtu 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/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt index 03551ac8dbe..6c1614f9882 100644 --- a/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt +++ b/src/coreComponents/integrationTests/coverageSmokeTests/CMakeLists.txt @@ -185,13 +185,106 @@ function( add_coverage_smoke_test ) 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}" ) + 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 From 82cc30871893d50c382064a85620112b5c88133e Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 11:46:54 -0400 Subject: [PATCH 10/19] ci: add native LLVM coverage job --- .github/codecov/verification.gpg.asc | 52 ---- .github/coverage-thresholds.json | 7 + .github/workflows/build_and_test.yml | 159 ++++++++---- .github/workflows/ci_tests.yml | 53 ++-- README.md | 1 - scripts/check_coverage_thresholds.py | 233 ++++++++++++++++++ scripts/ci_build_and_test_in_container.sh | 209 ++++++++++++---- scripts/llvm_source_branch_coverage.sh | 129 ++++++++-- .../tests/test_check_coverage_thresholds.py | 123 +++++++++ src/docs/sphinx/buildGuide/BuildProcess.rst | 7 +- .../Contributing/CodeGeosFeatures.rst | 15 +- 11 files changed, 789 insertions(+), 199 deletions(-) delete mode 100644 .github/codecov/verification.gpg.asc create mode 100644 .github/coverage-thresholds.json create mode 100755 scripts/check_coverage_thresholds.py create mode 100755 scripts/tests/test_check_coverage_thresholds.py 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..cae46bb3195 --- /dev/null +++ b/.github/coverage-thresholds.json @@ -0,0 +1,7 @@ +{ + "schema_version": 1, + "scope": "src/coreComponents", + "minimum_basis_points": { + "branches": 5001 + } +} diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index d19f0ff4cc4..1a37f15cf3f 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -20,7 +20,7 @@ on: required: false type: string default: '' - CODE_COVERAGE: + LLVM_SOURCE_COVERAGE: required: false type: boolean default: false @@ -88,8 +88,6 @@ on: required: false type: string secrets: - CODECOV_TOKEN: - required: false GOOGLE_CLOUD_GCP: required: false jobs: @@ -144,18 +142,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,11 +149,17 @@ 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 }} 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 @@ -239,6 +231,25 @@ 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_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}") + script_args+=(--llvm-source-coverage --coverage-output-dir ${COVERAGE_ARTIFACT_MOUNT_POINT}) + fi + if [ ! -z "${{ inputs.GEOS_ENABLE_BOUNDS_CHECK }}" ]; then script_args+=(--geos-enable-bounds-check "${{ inputs.GEOS_ENABLE_BOUNDS_CHECK }}") fi @@ -286,7 +297,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}" @@ -300,10 +311,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 @@ -348,7 +355,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[@]}" @@ -360,7 +367,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=$? @@ -418,34 +425,90 @@ jobs: exit ${EXIT_STATUS} - - name: Prepare pip for Codecov - if: inputs.CODE_COVERAGE + - name: Validate compact LLVM coverage 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 + 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 + 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-objects.txt|coverage-status.md|coverage-summary.json|coverage-summary.md|ctest.log|llvm-summary.json|mapping-integrity.log|native-export.log|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 && steps.validate_llvm_coverage_artifact.outcome == 'success' }} + run: | + printf '## LLVM source coverage\n\n' >> "${GITHUB_STEP_SUMMARY}" + for report in coverage-status.md coverage-summary.md; do + report_path="${LLVM_COVERAGE_ARTIFACT_DIR}/${report}" + if [[ -f "${report_path}" && ! -L "${report_path}" ]]; then + cat "${report_path}" >> "${GITHUB_STEP_SUMMARY}" + else + printf '%s is unavailable; inspect the job log.\n\n' "${report}" \ + >> "${GITHUB_STEP_SUMMARY}" + fi + done + + - name: Retain compact LLVM coverage report + if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE && steps.validate_llvm_coverage_artifact.outcome == 'success' }} + uses: actions/upload-artifact@v4.6.2 with: - files: ${GITHUB_WORKSPACE}/geos_coverage.info.cleaned - fail_ci_if_error: true - use_pypi: true - use_legacy_upload_endpoint: true - 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 + name: llvm-source-coverage-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/CMakeCache.txt + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/branch-summary.txt + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-objects.txt + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-status.md + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-summary.json + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-summary.md + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/ctest.log + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/llvm-summary.json + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/mapping-integrity.log + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/native-export.log + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/summary-export.log + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/toolchain.txt + if-no-files-found: warn + retention-days: 7 + + - name: Remove temporary LLVM coverage files + if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE }} + run: | + if [[ -z "${LLVM_COVERAGE_ARTIFACT_DIR:-}" || + ! -d "${LLVM_COVERAGE_ARTIFACT_DIR}" || + -L "${LLVM_COVERAGE_ARTIFACT_DIR}" ]]; then + exit 0 + fi + 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}" - name: Show sccache summary if: ${{ always() && inputs.USE_SCCACHE }} diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 7ab583d9683..9d09e2028bb 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -53,7 +53,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. @@ -295,26 +301,26 @@ 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 + uses: ./.github/workflows/build_and_test.yml + with: + BUILD_AND_TEST_CLI_ARGS: "--build-exe-only" + BUILD_SHARED_LIBS: "ON" + CMAKE_BUILD_TYPE: RelWithDebInfo + 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=12 --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 + NPROC: "12" + RUNS_ON: streak2 + USE_SCCACHE: false # mac_builds: @@ -440,6 +446,7 @@ jobs: - check_code_rules - cpu_builds - cuda_builds + - llvm_source_coverage - run_integrated_tests if: ${{ always() }} steps: @@ -449,12 +456,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/scripts/check_coverage_thresholds.py b/scripts/check_coverage_thresholds.py new file mode 100755 index 00000000000..9f6e5b35e33 --- /dev/null +++ b/scripts/check_coverage_thresholds.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-only + +"""Validate and enforce repository-owned LLVM coverage thresholds.""" + +import argparse +import json +import math +import pathlib +import sys + + +CANONICAL_METRICS = ( "regions", "functions", "lines", "branches" ) +SUPPLEMENTAL_METRICS = ( + ( "native_branch_outcomes", "Native branch outcomes" ), +) + + +def require_schema_version( document: dict, description: str ) -> None: + version = document.get( "schema_version" ) + if isinstance( version, bool ) or not isinstance( version, int ) or version != 1: + raise ValueError( f"{description} schema_version must be the integer 1" ) + + +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 validate_metric( metric: object, description: str ) -> dict: + if not isinstance( metric, dict ): + raise ValueError( f"{description} must be an object" ) + + covered = require_integer( metric.get( "covered" ), f"{description}.covered" ) + total = require_integer( metric.get( "total" ), f"{description}.total", 1 ) + 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, 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_summary( document: dict ) -> dict: + require_schema_version( document, "coverage summary" ) + scope = document.get( "scope" ) + if not isinstance( scope, str ) or not scope: + raise ValueError( "coverage summary scope must be a nonempty string" ) + + excluded_regex = document.get( "excluded_regex" ) + if not isinstance( excluded_regex, str ) or not excluded_regex: + raise ValueError( "coverage summary excluded_regex must be nonempty" ) + + 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" ) + tool_major = require_integer( tool.get( "major" ), "tool.major", 1 ) + + raw_inputs = document.get( "inputs" ) + if not isinstance( raw_inputs, dict ): + raise ValueError( "coverage summary inputs must be an object" ) + 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" ) + metrics = { + name: validate_metric( raw_metrics.get( name ), f"metrics.{name}" ) + for name in CANONICAL_METRICS + } + + raw_supplemental = document.get( "supplemental" ) + if not isinstance( raw_supplemental, dict ): + raise ValueError( "coverage summary supplemental must be an object" ) + supplemental = { + name: validate_metric( raw_supplemental.get( name ), f"supplemental.{name}" ) + for name, _ in SUPPLEMENTAL_METRICS + } + return { + "scope": scope, + "excluded_regex": excluded_regex, + "tool": { "name": "llvm-cov", "major": tool_major }, + "inputs": inputs, + "metrics": metrics, + "supplemental": supplemental, + } + + +def validate_policy( document: dict, expected_scope: str ) -> dict: + require_schema_version( document, "coverage policy" ) + 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:.6f}%" + + +def build_markdown( summary: dict, thresholds: dict ) -> tuple[str, bool]: + rows = [ + "### Coverage metrics", + "", + f"Scope: `{summary['scope']}`", + "", + "| Metric | Covered | Total | Percent | Minimum | Status |", + "|---|---:|---:|---:|---:|:---:|", + ] + all_passed = True + display_names = { + "regions": "Regions", + "functions": "Functions", + "lines": "Lines", + "branches": "Canonical branches", + } + for name in CANONICAL_METRICS: + metric = summary["metrics"][name] + threshold = thresholds.get( name ) + if threshold is None: + minimum = "—" + status = "Report only" + else: + passed = metric["covered"] * 10000 >= threshold * metric["total"] + all_passed = all_passed and passed + minimum = f"{threshold / 100:.2f}%" + status = "PASS" if passed else "FAIL" + rows.append( + f"| {display_names[name]} | {metric['covered']:,} | " + f"{metric['total']:,} | " + f"{percent_text(metric['covered'], metric['total'])} | " + f"{minimum} | {status} |" + ) + + for name, display_name in SUPPLEMENTAL_METRICS: + metric = summary["supplemental"][name] + rows.append( + f"| {display_name} | {metric['covered']:,} | " + f"{metric['total']:,} | " + f"{percent_text(metric['covered'], metric['total'])} | — | Diagnostic |" + ) + + rows.extend( ( "", "Thresholds are compared using exact integer counts." ) ) + 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 + + print( markdown, end="" ) + if args.markdown is not None: + args.markdown.parent.mkdir( parents=True, exist_ok=True ) + args.markdown.write_text( markdown, encoding="utf-8" ) + 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 9fa8ac1086d..ba6444ecbf9 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -154,8 +154,8 @@ 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 compact LLVM source-coverage artifacts are written. --ctest-parallel-level N Number of tests ctest may run in parallel. --data-basename output.tar.gz @@ -177,6 +177,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 +211,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-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 +228,8 @@ UPLOAD_TEST_BASELINES=false TEST_CODE_STYLE=false TEST_DOCUMENTATION=false ENABLE_TRILINOS=OFF -CODE_COVERAGE=false +LLVM_SOURCE_COVERAGE=false +COVERAGE_OUTPUT_DIR="" CTEST_PARALLEL_LEVEL_ARG="" NPROC="$(nproc)" GEOS_ENABLE_BOUNDS_CHECK=ON @@ -235,7 +239,6 @@ USE_SCCACHE=false CMAKE_CUDA_ARCHITECTURES_ARGS=() CMAKE_NATIVE_ARCHITECTURE_ARGS=() ATS_CMAKE_ARGS=() -LCOV_CMAKE_ARGS="" eval set -- ${args} while : @@ -249,6 +252,7 @@ do --cmake-cuda-architectures) CMAKE_CUDA_ARCHITECTURES_ARGS+=("-DCMAKE_CUDA_ARCHITECTURES=$2") shift 2;; + --coverage-output-dir) COVERAGE_OUTPUT_DIR=$2; shift 2;; --ninja) BUILD_GENERATOR=$1; shift;; @@ -270,6 +274,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 +286,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 +423,41 @@ 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 - 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 - done -fi + # 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 -exec "${GEOS_REAL_LCOV}" "${extra_args[@]}" "$@" -EOF - or_die chmod +x "${LCOV_WRAPPER}" - LCOV_CMAKE_ARGS="-DLCOV_EXECUTABLE=${LCOV_WRAPPER}" - fi + for llvm_tool in llvm-cov-20 llvm-profdata-20 llvm-readelf-20; do + or_die command -v "${llvm_tool}" + done + 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 \ + llvm-summary.json \ + mapping-integrity.log \ + native-export.log \ + 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_check_coverage_thresholds.py' fi # The -DBLT_MPI_COMMAND_APPEND="--allow-run-as-root;--oversubscribe" option is added for OpenMPI. @@ -468,6 +474,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 +501,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 +525,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 +555,112 @@ 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 + 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 12 \ + --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_gate_status=0 + if [[ ${coverage_report_status} -eq 0 ]]; then + 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=$? + else + coverage_gate_status=1 + 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 ]]; then + coverage_overall_status=FAIL + fi + + coverage_phase_status() + { + if [[ "$1" -eq 0 ]]; then + printf 'PASS' + else + printf 'FAIL (%d)' "$1" + fi + } + + coverage_status_markdown="${COVERAGE_OUTPUT_DIR}/coverage-status.md" + coverage_status_tmp="${coverage_status_markdown}.tmp" + { + printf '### Overall coverage job: **%s**\n\n' "${coverage_overall_status}" + printf '| Phase | Status |\n' + printf '|---|:---:|\n' + printf '| Coverage smoke CTest | %s |\n' "$(coverage_phase_status "${coverage_ctest_status}")" + printf '| LLVM report integrity | %s |\n' "$(coverage_phase_status "${coverage_report_status}")" + printf '| Repository threshold gate | %s |\n' "$(coverage_phase_status "${coverage_gate_status}")" + printf '\n' + } > "${coverage_status_tmp}" + or_die mv -- "${coverage_status_tmp}" "${coverage_status_markdown}" + + if [[ "${coverage_overall_status}" = FAIL ]]; then + echo "LLVM source coverage failed: ctest=${coverage_ctest_status}, " \ + "report=${coverage_report_status}, gate=${coverage_gate_status}." >&2 + exit 1 + fi + exit 0 fi # Run the unit tests (excluding previously ran checks). diff --git a/scripts/llvm_source_branch_coverage.sh b/scripts/llvm_source_branch_coverage.sh index c23eb88da79..ae6a7aad6b8 100755 --- a/scripts/llvm_source_branch_coverage.sh +++ b/scripts/llvm_source_branch_coverage.sh @@ -1,14 +1,11 @@ #!/usr/bin/env bash # SPDX-License-Identifier: LGPL-2.1-only # -# Merge Clang source-coverage profiles and report three production branch metrics: +# 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 Clang. Template and -# inline-function instantiations can appear more than once. -# definition The same LCOV outcomes after llvm-cov combines execution counts -# for C++ function instantiations. No source condition is excluded -# based on whether it was covered. +# 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. @@ -42,9 +39,10 @@ Typical profile collection: 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, native.info, -definition.info, export logs, and branch-summary.txt. The JSON and .info files -make every numerator and denominator auditable with LLVM and LCOV tooling. +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 } @@ -62,7 +60,23 @@ 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)" -rm -f -- "${output_dir}/branch-summary.txt" "${output_dir}/branch-summary.txt.tmp" +# 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 @@ -202,20 +216,14 @@ for object in "${coverage_objects[@]:1}"; do object_args+=( -object "${object}" ) done -# Escape a literal path for LLVM's regular-expression parser. Keeping the -# inclusion boundary anchored prevents similarly named sibling trees from -# entering the denominator. -source_regex="$(printf '%s' "${source_dir}" | sed 's/[][(){}.^$*+?|\\]/\\&/g')" -source_regex="^${source_regex}/" production_exclude_regex='/(unitTests|unitTestUtilities|integrationTests|tests|examples|benchmarks|testingUtilities)/' production_exclude_regex+='|/codingUtilities/UnitTestUtilities[.]hpp$' native_info="${output_dir}/native.info" -definition_info="${output_dir}/definition.info" llvm_summary="${output_dir}/llvm-summary.json" common_export_args=( -instr-profile "${profdata}" - -include-filename-regex "${source_regex}" + --sources "${source_dir}" -ignore-filename-regex "${production_exclude_regex}" -num-threads=1 ) @@ -275,12 +283,15 @@ run_export() 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 -unify-instantiations=false -run_export "${definition_info}" "${output_dir}/definition-export.log" \ - -format=lcov -unify-instantiations=true + -format=lcov "${native_instantiation_args[@]}" run_export "${llvm_summary}" "${output_dir}/summary-export.log" \ - -format=text -summary-only -unify-instantiations=false + -format=text -summary-only summarize_branches() { @@ -302,8 +313,6 @@ summarize_branches() } read -r native_covered native_total native_percent < <(summarize_branches "${native_info}") -read -r definition_covered definition_total definition_percent \ - < <(summarize_branches "${definition_info}") read -r canonical_covered canonical_total canonical_percent < <( "${python}" - "${llvm_summary}" <<'PY' import json @@ -323,7 +332,7 @@ profile_count="$(wc -l < "${profile_list}")" object_count="${#coverage_objects[@]}" zero_hash_mappings="${mismatch_details}" -if (( canonical_total == 0 || native_total == 0 || definition_total == 0 )); then +if (( canonical_total == 0 || native_total == 0 )); then echo "LLVM reported a zero production branch denominator." >&2 exit 1 fi @@ -340,8 +349,74 @@ summary_tmp="${summary}.tmp" "${canonical_covered}" "${canonical_total}" "${canonical_percent}" printf 'native LCOV branch outcomes: %d/%d (%.6f%%)\n' \ "${native_covered}" "${native_total}" "${native_percent}" - printf 'definition-normalized LCOV branch outcomes: %d/%d (%.6f%%)\n' \ - "${definition_covered}" "${definition_total}" "${definition_percent}" - printf 'collapsed branch outcomes: %d\n' "$(( native_total - definition_total ))" } | 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}" <<'PY' +import json +import os +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, +) = sys.argv[1:] + +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 ) + 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"] ) + +document = { + "schema_version": 1, + "scope": os.path.relpath( source_dir, project_root ), + "excluded_regex": excluded_regex, + "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, + "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/tests/test_check_coverage_thresholds.py b/scripts/tests/test_check_coverage_thresholds.py new file mode 100755 index 00000000000..f6fbb76b7f7 --- /dev/null +++ b/scripts/tests/test_check_coverage_thresholds.py @@ -0,0 +1,123 @@ +#!/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, 6 ), + } + + +def summary( branch_covered=1, branch_total=2 ): + return { + "schema_version": 1, + "scope": "src/coreComponents", + "excluded_regex": "/unitTests/", + "tool": { "name": "llvm-cov", "major": 20 }, + "inputs": { + "profiles": 10, + "coverage_objects": 2, + "zero_hash_mappings": 3, + }, + "metrics": { + "regions": metric( 1, 2 ), + "functions": metric( 1, 2 ), + "lines": metric( 1, 2 ), + "branches": metric( branch_covered, branch_total ), + }, + "supplemental": { + "native_branch_outcomes": metric( 1, 4 ), + }, + } + + +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.000000%", markdown ) + + def test_one_count_below_threshold_fails( self ): + validated = coverage_gate.validate_summary( summary( 49, 100 ) ) + _, passed = coverage_gate.build_markdown( + validated, { "branches": 5000 } + ) + self.assertFalse( passed ) + + def test_integer_comparison_avoids_rounding_up( self ): + validated = coverage_gate.validate_summary( summary( 1, 3 ) ) + _, passed = coverage_gate.build_markdown( + validated, { "branches": 3334 } + ) + self.assertFalse( passed ) + + 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 1" ): + 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_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_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( "Report only", markdown ) + + 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, { "branches": 5001 } ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/docs/sphinx/buildGuide/BuildProcess.rst b/src/docs/sphinx/buildGuide/BuildProcess.rst index 3fcff6c15b3..c71b9b8bd13 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 legacy gcov-compatible code coverage ``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,8 @@ 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`` and ``ENABLE_COVERAGE=OFF``. The +``scripts/llvm_source_branch_coverage.sh`` helper 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..789eb0cb606 100644 --- a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst +++ b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst @@ -161,10 +161,17 @@ 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 canonical branch floor in +``.github/coverage-thresholds.json``. Regions, functions, lines, 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 compact workflow artifact; no hosted coverage service +or secret is required. + +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 ^^^^^^^^^^^^^^^^^^^^^^^^^^^ From a5d134d733e4596cb8a1c2e15be66e3589100add Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 12:11:45 -0400 Subject: [PATCH 11/19] ci: improve coverage job summary --- .github/workflows/build_and_test.yml | 44 +++- scripts/check_coverage_thresholds.py | 189 +++++++++++++++--- scripts/ci_build_and_test_in_container.sh | 103 +++++++++- scripts/llvm_source_branch_coverage.sh | 33 ++- .../tests/test_check_coverage_thresholds.py | 163 ++++++++++++++- 5 files changed, 476 insertions(+), 56 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 1a37f15cf3f..6084c46afc6 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -153,6 +153,7 @@ jobs: 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 }} run: | # Those two bash arrays will be populated depending on the required options, # and expended as CLI arguments for the docker and scripts calls. @@ -247,6 +248,7 @@ jobs: 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}) fi @@ -462,18 +464,40 @@ jobs: fi - name: Publish LLVM coverage summary - if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE && steps.validate_llvm_coverage_artifact.outcome == 'success' }} + if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE }} + env: + CI_COVERAGE_ARTIFACT_VALIDATION: ${{ steps.validate_llvm_coverage_artifact.outcome }} run: | printf '## LLVM source coverage\n\n' >> "${GITHUB_STEP_SUMMARY}" - for report in coverage-status.md coverage-summary.md; do - report_path="${LLVM_COVERAGE_ARTIFACT_DIR}/${report}" - if [[ -f "${report_path}" && ! -L "${report_path}" ]]; then - cat "${report_path}" >> "${GITHUB_STEP_SUMMARY}" - else - printf '%s is unavailable; inspect the job log.\n\n' "${report}" \ - >> "${GITHUB_STEP_SUMMARY}" - fi - done + if [[ "${CI_COVERAGE_ARTIFACT_VALIDATION}" != success ]]; then + { + printf '### ❌ Coverage results unavailable\n\n' + printf '| Validation | Result | Details |\n' + printf '|---|:---:|---|\n' + printf '| Compact 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 - name: Retain compact LLVM coverage report if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE && steps.validate_llvm_coverage_artifact.outcome == 'success' }} diff --git a/scripts/check_coverage_thresholds.py b/scripts/check_coverage_thresholds.py index 9f6e5b35e33..57973d32829 100755 --- a/scripts/check_coverage_thresholds.py +++ b/scripts/check_coverage_thresholds.py @@ -4,22 +4,36 @@ """Validate and enforce repository-owned LLVM coverage thresholds.""" import argparse +import html import json import math import pathlib import sys +COVERAGE_SUMMARY_SCHEMA_VERSION = 2 +COVERAGE_POLICY_SCHEMA_VERSION = 1 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" ) -def require_schema_version( document: dict, description: str ) -> None: +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 != 1: - raise ValueError( f"{description} schema_version must be the integer 1" ) + 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: @@ -70,8 +84,46 @@ def validate_metric( metric: object, description: str ) -> dict: } +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" ) + + scope_prefix = f"{scope.rstrip( '/' )}/" + 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 = raw_gap.get( "path" ) + if not isinstance( path, str ) or not path.startswith( scope_prefix ): + raise ValueError( f"top_branch_gaps[{index}].path is outside the scope" ) + normalized_path = pathlib.PurePosixPath( path ) + if ( + len( path ) == len( scope_prefix ) + or normalized_path.as_posix() != path + or ".." in normalized_path.parts + or 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}]" ) + 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" ) + require_schema_version( + document, "coverage summary", COVERAGE_SUMMARY_SCHEMA_VERSION + ) scope = document.get( "scope" ) if not isinstance( scope, str ) or not scope: raise ValueError( "coverage summary scope must be a nonempty string" ) @@ -113,18 +165,24 @@ def validate_summary( document: dict ) -> dict: 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" ) return { "scope": scope, "excluded_regex": excluded_regex, "tool": { "name": "llvm-cov", "major": tool_major }, "inputs": inputs, "metrics": metrics, + "top_branch_gaps": branch_gaps, "supplemental": supplemental, } def validate_policy( document: dict, expected_scope: str ) -> dict: - require_schema_version( document, "coverage policy" ) + 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 " @@ -148,52 +206,127 @@ def validate_policy( document: dict, expected_scope: str ) -> dict: def percent_text( covered: int, total: int ) -> str: - return f"{100.0 * covered / total:.6f}%" + return f"{100.0 * covered / total:.2f}%" def build_markdown( summary: dict, thresholds: dict ) -> tuple[str, bool]: - rows = [ - "### Coverage metrics", - "", - f"Scope: `{summary['scope']}`", - "", - "| Metric | Covered | Total | Percent | Minimum | Status |", - "|---|---:|---:|---:|---:|:---:|", - ] - all_passed = True + 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", } - for name in CANONICAL_METRICS: + metric_rows = [] + for name in DISPLAY_METRICS: metric = summary["metrics"][name] threshold = thresholds.get( name ) if threshold is None: - minimum = "—" - status = "Report only" + requirement = "—" + result = "ℹ️ Measured" else: - passed = metric["covered"] * 10000 >= threshold * metric["total"] - all_passed = all_passed and passed - minimum = f"{threshold / 100:.2f}%" - status = "PASS" if passed else "FAIL" - rows.append( + 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['total']:,} | " + f"{metric['not_covered']:,} | {metric['total']:,} | " f"{percent_text(metric['covered'], metric['total'])} | " - f"{minimum} | {status} |" + 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']:,} | " - f"{percent_text(metric['covered'], metric['total'])} | — | Diagnostic |" + f"{metric['total']:,} | {percent_text(metric['covered'], metric['total'])} |" ) - rows.extend( ( "", "Thresholds are compared using exact integer counts." ) ) + 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 diff --git a/scripts/ci_build_and_test_in_container.sh b/scripts/ci_build_and_test_in_container.sh index ba6444ecbf9..ffd8f81caea 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -558,6 +558,7 @@ fi if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then LLVM_TEST_PROFILE_DIR=/tmp/geos-coverage-profiles LLVM_REPORT_DIR=/tmp/geos-coverage-report + LLVM_CTEST_PARALLEL=12 or_die mkdir -p "${LLVM_TEST_PROFILE_DIR}" "${LLVM_REPORT_DIR}" export OMP_NUM_THREADS=1 @@ -587,7 +588,7 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then coverage_ctest_status=0 ctest --test-dir "${GEOS_BUILD_DIR}" \ - --parallel 12 \ + --parallel "${LLVM_CTEST_PARALLEL}" \ --progress \ --output-on-failure \ --output-log "${COVERAGE_OUTPUT_DIR}/ctest.log" \ @@ -600,14 +601,14 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then "${LLVM_REPORT_DIR}" || coverage_report_status=$? 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=$? - else - coverage_gate_status=1 fi for report_file in \ @@ -635,22 +636,102 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then coverage_phase_status() { + if [[ "${2:-true}" != true ]]; then + printf '⏭️ Not run' + return + fi if [[ "$1" -eq 0 ]]; then - printf 'PASS' + printf '✅ Pass' else - printf 'FAIL (%d)' "$1" + 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 + 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 { - printf '### Overall coverage job: **%s**\n\n' "${coverage_overall_status}" - printf '| Phase | Status |\n' - printf '|---|:---:|\n' - printf '| Coverage smoke CTest | %s |\n' "$(coverage_phase_status "${coverage_ctest_status}")" - printf '| LLVM report integrity | %s |\n' "$(coverage_phase_status "${coverage_report_status}")" - printf '| Repository threshold gate | %s |\n' "$(coverage_phase_status "${coverage_gate_status}")" + if [[ "${coverage_overall_status}" = PASS ]]; then + printf '### ✅ LLVM source coverage passed\n\n' + else + printf '### ❌ LLVM source coverage failed\n\n' + fi + 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 '\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}" diff --git a/scripts/llvm_source_branch_coverage.sh b/scripts/llvm_source_branch_coverage.sh index ae6a7aad6b8..a26053a26c5 100755 --- a/scripts/llvm_source_branch_coverage.sh +++ b/scripts/llvm_source_branch_coverage.sh @@ -396,8 +396,38 @@ 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 = [] +file_branch_covered = 0 +file_branch_total = 0 +for source in llvm_document["data"][0]["files"]: + filename = os.path.realpath( source["filename"] ) + try: + in_scope = os.path.commonpath( ( source_root, filename ) ) == source_root + except ValueError: + in_scope = False + if not in_scope: + continue + branches = source["summary"]["branches"] + branch_metric = metric( branches["covered"], branches["count"] ) + file_branch_covered += branch_metric["covered"] + file_branch_total += branch_metric["total"] + if branch_metric["not_covered"] == 0: + continue + branch_gaps.append( + { + "path": os.path.relpath( filename, project_root ).replace( os.sep, "/" ), + **branch_metric, + } + ) +branch_gaps.sort( key=lambda gap: ( -gap["not_covered"], gap["path"] ) ) +if ( file_branch_covered, file_branch_total ) != ( + canonical["branches"]["covered"], canonical["branches"]["total"] +): + raise ValueError( "per-file branch counts do not match canonical totals" ) + document = { - "schema_version": 1, + "schema_version": 2, "scope": os.path.relpath( source_dir, project_root ), "excluded_regex": excluded_regex, "tool": { @@ -410,6 +440,7 @@ document = { "zero_hash_mappings": int( zero_hash_mappings ), }, "metrics": canonical, + "top_branch_gaps": branch_gaps[:5], "supplemental": { "native_branch_outcomes": metric( native_covered, native_total ), }, diff --git a/scripts/tests/test_check_coverage_thresholds.py b/scripts/tests/test_check_coverage_thresholds.py index f6fbb76b7f7..99d016fa30a 100755 --- a/scripts/tests/test_check_coverage_thresholds.py +++ b/scripts/tests/test_check_coverage_thresholds.py @@ -25,7 +25,7 @@ def metric( covered, total ): def summary( branch_covered=1, branch_total=2 ): return { - "schema_version": 1, + "schema_version": 2, "scope": "src/coreComponents", "excluded_regex": "/unitTests/", "tool": { "name": "llvm-cov", "major": 20 }, @@ -40,6 +40,12 @@ def summary( branch_covered=1, branch_total=2 ): "lines": metric( 1, 2 ), "branches": metric( branch_covered, branch_total ), }, + "top_branch_gaps": [ + { + "path": "src/coreComponents/example.cpp", + **metric( 0, 1 ), + } + ], "supplemental": { "native_branch_outcomes": metric( 1, 4 ), }, @@ -53,21 +59,35 @@ def test_exact_threshold_passes( self ): validated, { "branches": 5000 } ) self.assertTrue( passed ) - self.assertIn( "50.000000%", markdown ) + 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 ) ) - _, passed = coverage_gate.build_markdown( + 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 ) ) - _, passed = coverage_gate.build_markdown( + 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() @@ -78,7 +98,7 @@ def test_inconsistent_counts_are_rejected( self ): def test_boolean_schema_version_is_rejected( self ): document = summary() document["schema_version"] = True - with self.assertRaisesRegex( ValueError, "integer 1" ): + with self.assertRaisesRegex( ValueError, "integer 2" ): coverage_gate.validate_summary( document ) def test_invalid_metadata_is_rejected( self ): @@ -109,7 +129,138 @@ def test_non_gated_metrics_are_report_only( self ): ) self.assertTrue( passed ) self.assertIn( "Functions", markdown ) - self.assertIn( "Report only", markdown ) + self.assertIn( "ℹ️ Measured", 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 | 0 | 1 | 1 | 0.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( 19686, 35810 ) + 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, 30 ), + }, + { + "path": "src/coreComponents/nested/b.cpp", + **metric( 2, 7 ), + }, + ] + 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 | 20 | 30 | 33.33% |", 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 and unique" ): + 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" From 86cf87613522fcda4dbc833e7f1f883a8d05f1d1 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 13:36:16 -0400 Subject: [PATCH 12/19] ci: report pull request coverage changes --- .github/workflows/build_and_test.yml | 149 +- .github/workflows/ci_tests.yml | 9 + scripts/check_coverage_thresholds.py | 548 +++++++- scripts/ci_build_and_test_in_container.sh | 83 +- scripts/compare_pr_coverage.py | 1214 +++++++++++++++++ scripts/fetch_coverage_baseline.py | 802 +++++++++++ scripts/llvm_source_branch_coverage.sh | 347 ++++- .../tests/test_check_coverage_thresholds.py | 287 +++- scripts/tests/test_compare_pr_coverage.py | 539 ++++++++ scripts/tests/test_fetch_coverage_baseline.py | 739 ++++++++++ src/docs/sphinx/buildGuide/BuildProcess.rst | 10 +- .../Contributing/CodeGeosFeatures.rst | 10 + 12 files changed, 4658 insertions(+), 79 deletions(-) create mode 100755 scripts/compare_pr_coverage.py create mode 100755 scripts/fetch_coverage_baseline.py create mode 100755 scripts/tests/test_compare_pr_coverage.py create mode 100755 scripts/tests/test_fetch_coverage_baseline.py diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 6084c46afc6..bb495c3a89c 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -24,6 +24,14 @@ on: 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 @@ -133,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')) }} @@ -154,6 +164,12 @@ jobs: 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 }} run: | # Those two bash arrays will be populated depending on the required options, # and expended as CLI arguments for the docker and scripts calls. @@ -176,7 +192,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}) @@ -233,6 +249,35 @@ jobs: 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 @@ -249,7 +294,43 @@ jobs: 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}) + 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 @@ -425,6 +506,14 @@ jobs: echo "GCP upload disabled." fi + 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 + exit ${EXIT_STATUS} - name: Validate compact LLVM coverage report @@ -449,7 +538,7 @@ jobs: fi while IFS= read -r artifact_file; do case "$(basename -- "${artifact_file}")" in - CMakeCache.txt|branch-summary.txt|coverage-objects.txt|coverage-status.md|coverage-summary.json|coverage-summary.md|ctest.log|llvm-summary.json|mapping-integrity.log|native-export.log|summary-export.log|toolchain.txt) ;; + CMakeCache.txt|branch-summary.txt|coverage-baseline-status.json|coverage-objects.txt|coverage-status.md|coverage-summary.json|coverage-summary.md|ctest.log|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 @@ -467,6 +556,7 @@ jobs: if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE }} env: 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 @@ -498,6 +588,18 @@ jobs: 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 compact LLVM coverage report if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE && steps.validate_llvm_coverage_artifact.outcome == 'success' }} @@ -507,6 +609,7 @@ jobs: path: | ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/CMakeCache.txt ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/branch-summary.txt + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-baseline-status.json ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-objects.txt ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-status.md ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-summary.json @@ -515,24 +618,44 @@ jobs: ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/llvm-summary.json ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/mapping-integrity.log ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/native-export.log + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/pr-coverage.json + ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/pr-coverage.md ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/summary-export.log ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/toolchain.txt 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 [[ -z "${LLVM_COVERAGE_ARTIFACT_DIR:-}" || - ! -d "${LLVM_COVERAGE_ARTIFACT_DIR}" || - -L "${LLVM_COVERAGE_ARTIFACT_DIR}" ]]; then - exit 0 + 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 - 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}" - name: Show sccache summary if: ${{ always() && inputs.USE_SCCACHE }} diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 9d09e2028bb..cc86a1b8e1f 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: @@ -304,11 +308,16 @@ jobs: 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 }} CTEST_PARALLEL_LEVEL: "12" DOCKER_IMAGE_TAG: ${{ needs.is_not_draft_pull_request.outputs.DOCKER_IMAGE_TAG }} DOCKER_REPOSITORY: geosx/ubuntu24.04-clang20 diff --git a/scripts/check_coverage_thresholds.py b/scripts/check_coverage_thresholds.py index 57973d32829..4df92267bc5 100755 --- a/scripts/check_coverage_thresholds.py +++ b/scripts/check_coverage_thresholds.py @@ -4,15 +4,18 @@ """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 = 2 +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 = ( @@ -21,6 +24,76 @@ 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 @@ -53,12 +126,68 @@ def require_integer( value, description: str, minimum: int = 0 ) -> int: return value -def validate_metric( metric: object, description: str ) -> dict: +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", 1 ) + total = require_integer( + metric.get( "total" ), f"{description}.total", minimum_total + ) not_covered = require_integer( metric.get( "not_covered" ), f"{description}.not_covered" ) @@ -72,7 +201,7 @@ def validate_metric( metric: object, description: str ) -> dict: 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, 6 ) + 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" ) @@ -84,30 +213,355 @@ def validate_metric( metric: object, description: str ) -> dict: } +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" ) - scope_prefix = f"{scope.rstrip( '/' )}/" 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 = raw_gap.get( "path" ) - if not isinstance( path, str ) or not path.startswith( scope_prefix ): - raise ValueError( f"top_branch_gaps[{index}].path is outside the scope" ) - normalized_path = pathlib.PurePosixPath( path ) - if ( - len( path ) == len( scope_prefix ) - or normalized_path.as_posix() != path - or ".." in normalized_path.parts - or path in seen_paths - ): + 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}]" ) + 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 } ) @@ -124,22 +578,46 @@ def validate_summary( document: dict ) -> dict: require_schema_version( document, "coverage summary", COVERAGE_SUMMARY_SCHEMA_VERSION ) - scope = document.get( "scope" ) - if not isinstance( scope, str ) or not scope: - raise ValueError( "coverage summary scope must be a nonempty string" ) - - excluded_regex = document.get( "excluded_regex" ) - if not isinstance( excluded_regex, str ) or not excluded_regex: - raise ValueError( "coverage summary excluded_regex must be nonempty" ) + 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( @@ -153,14 +631,24 @@ def validate_summary( document: dict ) -> dict: 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 @@ -168,12 +656,26 @@ def validate_summary( document: dict ) -> dict: 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, } diff --git a/scripts/ci_build_and_test_in_container.sh b/scripts/ci_build_and_test_in_container.sh index ffd8f81caea..46220b26402 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -156,6 +156,12 @@ Usage: $0 Optional override for CMAKE_CUDA_ARCHITECTURES. --coverage-output-dir /path/to/output Directory where compact LLVM source-coverage artifacts 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 @@ -211,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:,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 -- "$@") +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 @@ -230,6 +236,9 @@ TEST_DOCUMENTATION=false ENABLE_TRILINOS=OFF 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 @@ -252,6 +261,9 @@ 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; @@ -428,6 +440,27 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; 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 # The pinned Clang CI image intentionally contains the compiler and TPLs but # not the compiler-native coverage reporter or profile runtime. Install the @@ -435,6 +468,10 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then 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}" @@ -451,13 +488,15 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then 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_check_coverage_thresholds.py' + -p 'test_*coverage*.py' fi # The -DBLT_MPI_COMMAND_APPEND="--allow-run-as-root;--oversubscribe" option is added for OpenMPI. @@ -600,6 +639,26 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then "${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=$? + fi + coverage_gate_status=0 coverage_gate_ran=false if [[ ${coverage_report_status} -eq 0 ]]; then @@ -630,7 +689,8 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then coverage_overall_status=PASS if [[ ${coverage_ctest_status} -ne 0 || ${coverage_report_status} -ne 0 || - ${coverage_gate_status} -ne 0 ]]; then + ${coverage_gate_status} -ne 0 || + ${coverage_pr_status} -ne 0 ]]; then coverage_overall_status=FAIL fi @@ -684,6 +744,17 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then 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" @@ -721,6 +792,9 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then 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' @@ -738,7 +812,8 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then if [[ "${coverage_overall_status}" = FAIL ]]; then echo "LLVM source coverage failed: ctest=${coverage_ctest_status}, " \ - "report=${coverage_report_status}, gate=${coverage_gate_status}." >&2 + "report=${coverage_report_status}, gate=${coverage_gate_status}, " \ + "pr=${coverage_pr_status}." >&2 exit 1 fi exit 0 diff --git a/scripts/compare_pr_coverage.py b/scripts/compare_pr_coverage.py new file mode 100755 index 00000000000..ed0ad64ca9c --- /dev/null +++ b/scripts/compare_pr_coverage.py @@ -0,0 +1,1214 @@ +#!/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 ) + 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 index a26053a26c5..433ed62cfee 100755 --- a/scripts/llvm_source_branch_coverage.sh +++ b/scripts/llvm_source_branch_coverage.sh @@ -34,6 +34,15 @@ Environment overrides: 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' \ @@ -358,9 +367,13 @@ coverage_summary_tmp="${coverage_summary}.tmp" "${project_root}" "${source_dir}" "${production_exclude_regex}" \ "${compiler_major}" "${profile_count}" "${object_count}" \ "${zero_hash_mappings}" \ - "${native_covered}" "${native_total}" <<'PY' + "${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 ( @@ -375,8 +388,256 @@ import sys 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 ) @@ -385,6 +646,8 @@ 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, @@ -398,38 +661,91 @@ for name in ( "regions", "functions", "lines", "branches" ): source_root = os.path.realpath( source_dir ) branch_gaps = [] -file_branch_covered = 0 -file_branch_total = 0 +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"]: - filename = os.path.realpath( source["filename"] ) + 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 - branches = source["summary"]["branches"] - branch_metric = metric( branches["covered"], branches["count"] ) - file_branch_covered += branch_metric["covered"] - file_branch_total += branch_metric["total"] + 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": os.path.relpath( filename, project_root ).replace( os.sep, "/" ), + "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"] ) ) -if ( file_branch_covered, file_branch_total ) != ( - canonical["branches"]["covered"], canonical["branches"]["total"] -): - raise ValueError( "per-file branch counts do not match canonical totals" ) +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": 2, - "scope": os.path.relpath( source_dir, project_root ), + "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 ), @@ -440,6 +756,7 @@ document = { "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 ), diff --git a/scripts/tests/test_check_coverage_thresholds.py b/scripts/tests/test_check_coverage_thresholds.py index 99d016fa30a..f0372871928 100755 --- a/scripts/tests/test_check_coverage_thresholds.py +++ b/scripts/tests/test_check_coverage_thresholds.py @@ -19,37 +19,121 @@ def metric( covered, total ): "covered": covered, "total": total, "not_covered": total - covered, - "percent": round( 100.0 * covered / total, 6 ), + "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 ): - return { - "schema_version": 2, - "scope": "src/coreComponents", - "excluded_regex": "/unitTests/", + 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": { - "regions": metric( 1, 2 ), - "functions": metric( 1, 2 ), - "lines": metric( 1, 2 ), - "branches": metric( branch_covered, branch_total ), - }, - "top_branch_gaps": [ + "metrics": canonical, + "per_file_metrics": [ { "path": "src/coreComponents/example.cpp", - **metric( 0, 1 ), + "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 ): @@ -98,7 +182,7 @@ def test_inconsistent_counts_are_rejected( self ): def test_boolean_schema_version_is_rejected( self ): document = summary() document["schema_version"] = True - with self.assertRaisesRegex( ValueError, "integer 2" ): + with self.assertRaisesRegex( ValueError, "integer 3" ): coverage_gate.validate_summary( document ) def test_invalid_metadata_is_rejected( self ): @@ -107,12 +191,155 @@ def test_invalid_metadata_is_rejected( self ): 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, @@ -153,7 +380,7 @@ def test_markdown_golden_output( self ): | Source file | Covered | Missed | Total | Coverage | |---|---:|---:|---:|---:| -| example.cpp | 0 | 1 | 1 | 0.00% | +| example.cpp | 1 | 1 | 2 | 50.00% | Ranked by missed canonical branch outcomes within the production scope. @@ -182,7 +409,7 @@ def test_markdown_golden_output( self ): self.assertEqual( markdown, expected ) def test_markdown_presents_counts_inputs_and_diagnostics( self ): - document = summary( 19686, 35810 ) + document = summary( 12, 23 ) document["inputs"] = { "profiles": 3451, "coverage_objects": 29, @@ -195,11 +422,31 @@ def test_markdown_presents_counts_inputs_and_diagnostics( self ): document["top_branch_gaps"] = [ { "path": "src/coreComponents/a.cpp", - **metric( 10, 30 ), + **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", - **metric( 2, 7 ), + "metrics": { + "regions": metric( 0, 0 ), + "functions": metric( 0, 0 ), + "lines": metric( 0, 0 ), + "branches": metric( 2, 3 ), + }, }, ] validated = coverage_gate.validate_summary( document ) @@ -214,7 +461,7 @@ def test_markdown_presents_counts_inputs_and_diagnostics( self ): ) self.assertIn( "### Largest branch gaps", markdown ) self.assertLess( markdown.index( "a.cpp" ), markdown.index( "nested/b.cpp" ) ) - self.assertIn( "| a.cpp | 10 | 20 | 30 | 33.33% |", markdown ) + 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 ) @@ -242,7 +489,7 @@ def test_branch_gap_paths_must_be_unique_and_in_scope( self ): with self.subTest( path=invalid_path ): document = summary() document["top_branch_gaps"][0]["path"] = invalid_path - with self.assertRaisesRegex( ValueError, "normalized and unique" ): + with self.assertRaisesRegex( ValueError, "normalized" ): coverage_gate.validate_summary( document ) def test_branch_gaps_must_be_sorted_by_missed_count( self ): diff --git a/scripts/tests/test_compare_pr_coverage.py b/scripts/tests/test_compare_pr_coverage.py new file mode 100755 index 00000000000..07c9a0732d3 --- /dev/null +++ b/scripts/tests/test_compare_pr_coverage.py @@ -0,0 +1,539 @@ +#!/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( 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/src/docs/sphinx/buildGuide/BuildProcess.rst b/src/docs/sphinx/buildGuide/BuildProcess.rst index c71b9b8bd13..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_COVERAGE`` ``OFF`` Enables legacy gcov-compatible 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 @@ -83,6 +83,8 @@ Option Default Explanation =============================== ========= ============================================================================== For compiler-native Clang source coverage, configure a CPU shared-library build -with ``GEOS_ENABLE_LLVM_SOURCE_COVERAGE=ON`` and ``ENABLE_COVERAGE=OFF``. The -``scripts/llvm_source_branch_coverage.sh`` helper generates the production -report from the resulting raw profiles. +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 789eb0cb606..08eec62e601 100644 --- a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst +++ b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst @@ -170,6 +170,16 @@ being hard gates. The compiler-native report is published in the GitHub job summary and retained as a compact workflow artifact; no hosted coverage service or secret is required. +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 canonical branch floor remains the enforced policy. + 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. From d09567e65a6b8d3fb9fb7a48592d2b224e56da55 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Tue, 11 Aug 2026 13:53:27 -0400 Subject: [PATCH 13/19] ci: use 32-core coverage runner --- .github/workflows/ci_tests.yml | 10 +++++++--- scripts/ci_build_and_test_in_container.sh | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index cc86a1b8e1f..931ef37f2eb 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -318,17 +318,21 @@ jobs: 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=12 --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" + 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 - NPROC: "12" - RUNS_ON: streak2 + # 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 diff --git a/scripts/ci_build_and_test_in_container.sh b/scripts/ci_build_and_test_in_container.sh index 46220b26402..136aa4ec263 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -597,7 +597,7 @@ fi if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then LLVM_TEST_PROFILE_DIR=/tmp/geos-coverage-profiles LLVM_REPORT_DIR=/tmp/geos-coverage-report - LLVM_CTEST_PARALLEL=12 + LLVM_CTEST_PARALLEL="${CTEST_PARALLEL_LEVEL_ARG:-12}" or_die mkdir -p "${LLVM_TEST_PROFILE_DIR}" "${LLVM_REPORT_DIR}" export OMP_NUM_THREADS=1 From 8c1d6d06c39da9200e470589dae9dd5f6084fe7a Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Sun, 16 Aug 2026 21:45:53 -0400 Subject: [PATCH 14/19] Check label --- .github/workflows/ci_tests.yml | 1 + .../sphinx/developerGuide/Contributing/CodeGeosFeatures.rst | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 931ef37f2eb..ced6be430d9 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -329,6 +329,7 @@ jobs: 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" diff --git a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst index 08eec62e601..846e21b09e7 100644 --- a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst +++ b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst @@ -170,6 +170,10 @@ being hard gates. The compiler-native report is published in the GitHub job summary and retained as a compact workflow artifact; 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 From 1c45602c3f9d05004b04968e6aa486e051980afe Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Sun, 16 Aug 2026 22:09:44 -0400 Subject: [PATCH 15/19] Minor fix --- src/coreComponents/dataRepository/unitTests/testWrapper.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/coreComponents/dataRepository/unitTests/testWrapper.cpp b/src/coreComponents/dataRepository/unitTests/testWrapper.cpp index bbb53554720..4208404a00d 100644 --- a/src/coreComponents/dataRepository/unitTests/testWrapper.cpp +++ b/src/coreComponents/dataRepository/unitTests/testWrapper.cpp @@ -693,6 +693,7 @@ TEST_F( WrapperLimitsTest, Array2dValidateEmpty ) w.setLimits( 0.0, 1.0, wrapperLimits::LimitsMode::Error ); EXPECT_NO_THROW( w.validateLimits() ); +} TEST( WrapperStandardArrays, VirtualInterface ) { From 848a6e00f805a2ebd165ad82b471f3bb0f8fd959 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Sun, 16 Aug 2026 22:40:44 -0400 Subject: [PATCH 16/19] Improve code coverage summary --- .github/coverage-thresholds.json | 2 + scripts/ci_build_and_test_in_container.sh | 21 ++ scripts/render_coverage_review_summary.py | 193 ++++++++++++++++++ .../tests/test_check_coverage_thresholds.py | 18 +- .../test_render_coverage_review_summary.py | 111 ++++++++++ .../Contributing/CodeGeosFeatures.rst | 9 +- 6 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 scripts/render_coverage_review_summary.py create mode 100644 scripts/tests/test_render_coverage_review_summary.py diff --git a/.github/coverage-thresholds.json b/.github/coverage-thresholds.json index cae46bb3195..d34c1627305 100644 --- a/.github/coverage-thresholds.json +++ b/.github/coverage-thresholds.json @@ -2,6 +2,8 @@ "schema_version": 1, "scope": "src/coreComponents", "minimum_basis_points": { + "lines": 7001, + "functions": 7501, "branches": 5001 } } diff --git a/scripts/ci_build_and_test_in_container.sh b/scripts/ci_build_and_test_in_container.sh index 136aa4ec263..d7d64688a8d 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -777,12 +777,33 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then 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' \ diff --git a/scripts/render_coverage_review_summary.py b/scripts/render_coverage_review_summary.py new file mode 100644 index 00000000000..60558cbd1c9 --- /dev/null +++ b/scripts/render_coverage_review_summary.py @@ -0,0 +1,193 @@ +#!/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", +} + + +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( "--tests-result", required=True ) + parser.add_argument( "--tests-detail", required=True ) + 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"] ) + 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 index f0372871928..0b77b3c7cc3 100755 --- a/scripts/tests/test_check_coverage_thresholds.py +++ b/scripts/tests/test_check_coverage_thresholds.py @@ -358,6 +358,19 @@ def test_non_gated_metrics_are_report_only( self ): 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( @@ -514,7 +527,10 @@ def test_checked_in_policy_is_valid( self ): with policy_path.open( encoding="utf-8" ) as stream: policy = json.load( stream ) thresholds = coverage_gate.validate_policy( policy, "src/coreComponents" ) - self.assertEqual( thresholds, { "branches": 5001 } ) + self.assertEqual( + thresholds, + { "lines": 7001, "functions": 7501, "branches": 5001 }, + ) if __name__ == "__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..ff34c296d93 --- /dev/null +++ b/scripts/tests/test_render_coverage_review_summary.py @@ -0,0 +1,111 @@ +#!/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_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/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst index 846e21b09e7..327b65fc770 100644 --- a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst +++ b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst @@ -163,8 +163,9 @@ Code Coverage 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 canonical branch floor in -``.github/coverage-thresholds.json``. Regions, functions, lines, and the native +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 compact workflow artifact; no hosted coverage service @@ -182,7 +183,9 @@ 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 canonical branch floor remains the enforced policy. +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. From d07fda0669266cb2b8787277b37690991f32f721 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Sun, 16 Aug 2026 23:26:45 -0400 Subject: [PATCH 17/19] Improve summary --- .github/workflows/build_and_test.yml | 44 +- scripts/check_coverage_thresholds.py | 3 +- scripts/ci_build_and_test_in_container.sh | 25 +- scripts/compare_pr_coverage.py | 3 +- scripts/render_coverage_report_html.py | 623 ++++++++++++++++++ scripts/tests/test_compare_pr_coverage.py | 1 + .../tests/test_render_coverage_report_html.py | 130 ++++ .../Contributing/CodeGeosFeatures.rst | 6 +- 8 files changed, 809 insertions(+), 26 deletions(-) create mode 100644 scripts/render_coverage_report_html.py create mode 100644 scripts/tests/test_render_coverage_report_html.py diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index bb495c3a89c..2696a7e6f57 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -514,9 +514,23 @@ jobs: "${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 compact LLVM coverage report + - name: Validate LLVM coverage HTML report id: validate_llvm_coverage_artifact if: ${{ always() && inputs.LLVM_SOURCE_COVERAGE }} run: | @@ -526,6 +540,11 @@ jobs: 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 @@ -538,7 +557,7 @@ jobs: 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|llvm-summary.json|mapping-integrity.log|native-export.log|pr-coverage.json|pr-coverage.md|summary-export.log|toolchain.txt) ;; + 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 @@ -564,7 +583,7 @@ jobs: printf '### ❌ Coverage results unavailable\n\n' printf '| Validation | Result | Details |\n' printf '|---|:---:|---|\n' - printf '| Compact report artifact | ❌ Failed | Artifact validation failed; inspect the job log |\n' + printf '| HTML report artifact | ❌ Failed | Artifact validation failed; inspect the job log |\n' } >> "${GITHUB_STEP_SUMMARY}" exit 0 fi @@ -601,27 +620,12 @@ jobs: fi fi - - name: Retain compact LLVM coverage report + - 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 }}/CMakeCache.txt - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/branch-summary.txt - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-baseline-status.json - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-objects.txt - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-status.md - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-summary.json - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/coverage-summary.md - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/ctest.log - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/llvm-summary.json - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/mapping-integrity.log - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/native-export.log - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/pr-coverage.json - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/pr-coverage.md - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/summary-export.log - ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/toolchain.txt + path: ${{ env.LLVM_COVERAGE_ARTIFACT_DIR }}/index.html if-no-files-found: warn retention-days: 7 diff --git a/scripts/check_coverage_thresholds.py b/scripts/check_coverage_thresholds.py index 4df92267bc5..5d95baf09c7 100755 --- a/scripts/check_coverage_thresholds.py +++ b/scripts/check_coverage_thresholds.py @@ -857,10 +857,11 @@ def main() -> int: print( f"coverage threshold error: {error}", file=sys.stderr ) return 2 - print( markdown, end="" ) 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 diff --git a/scripts/ci_build_and_test_in_container.sh b/scripts/ci_build_and_test_in_container.sh index d7d64688a8d..ee8b69b1e7c 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -155,7 +155,7 @@ Usage: $0 --cmake-cuda-architectures ... Optional override for CMAKE_CUDA_ARCHITECTURES. --coverage-output-dir /path/to/output - Directory where compact LLVM source-coverage artifacts are written. + 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 @@ -485,6 +485,7 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then coverage-summary.json \ coverage-summary.md \ ctest.log \ + index.html \ llvm-summary.json \ mapping-integrity.log \ native-export.log \ @@ -657,6 +658,9 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then 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 @@ -668,6 +672,9 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then "${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 fi for report_file in \ @@ -831,10 +838,24 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then } > "${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}." >&2 + "pr=${coverage_pr_status}, html=${coverage_html_status}." >&2 + exit 1 + fi + if [[ ${coverage_html_status} -ne 0 ]]; then exit 1 fi exit 0 diff --git a/scripts/compare_pr_coverage.py b/scripts/compare_pr_coverage.py index ed0ad64ca9c..8f0a7e5e8a9 100755 --- a/scripts/compare_pr_coverage.py +++ b/scripts/compare_pr_coverage.py @@ -1203,7 +1203,8 @@ def main() -> int: write_atomic( output_json, json_text ) if output_markdown is not None: write_atomic( output_markdown, markdown ) - print( markdown, end="" ) + else: + print( markdown, end="" ) return 0 except ( OSError, ValueError ) as error: print( f"PR coverage comparison error: {error}", file=sys.stderr ) diff --git a/scripts/render_coverage_report_html.py b/scripts/render_coverage_report_html.py new file mode 100644 index 00000000000..caa004c21c9 --- /dev/null +++ b/scripts/render_coverage_report_html.py @@ -0,0 +1,623 @@ +#!/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 pathlib +import re +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 + + +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 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: + arguments.output.parent.mkdir( parents=True, exist_ok=True ) + arguments.output.write_text( + render_html( arguments.artifact_dir ), encoding="utf-8" + ) + 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/tests/test_compare_pr_coverage.py b/scripts/tests/test_compare_pr_coverage.py index 07c9a0732d3..15e541ee404 100755 --- a/scripts/tests/test_compare_pr_coverage.py +++ b/scripts/tests/test_compare_pr_coverage.py @@ -505,6 +505,7 @@ def test_cli_writes_compact_outputs_and_rejects_short_sha( self ) -> None: 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 ) 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..e6a1c23ff41 --- /dev/null +++ b/scripts/tests/test_render_coverage_report_html.py @@ -0,0 +1,130 @@ +#!/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 = coverage_html.render_html( artifact_dir ) + + 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/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst index 327b65fc770..e90d1f45b55 100644 --- a/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst +++ b/src/docs/sphinx/developerGuide/Contributing/CodeGeosFeatures.rst @@ -168,8 +168,10 @@ repository-owned lines, functions, and canonical branch floors in 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 compact workflow artifact; no hosted coverage service -or secret is required. +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 From 1d99450f318a6f0fd301f4a41ca527dd3a124ce6 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Sun, 16 Aug 2026 23:43:19 -0400 Subject: [PATCH 18/19] Fix file write --- scripts/render_coverage_report_html.py | 29 ++++++++++++++++--- .../tests/test_render_coverage_report_html.py | 5 ++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/scripts/render_coverage_report_html.py b/scripts/render_coverage_report_html.py index caa004c21c9..3d418ca1f6a 100644 --- a/scripts/render_coverage_report_html.py +++ b/scripts/render_coverage_report_html.py @@ -8,9 +8,11 @@ import argparse import html import json +import os import pathlib import re import sys +import tempfile SCRIPT_DIRECTORY = pathlib.Path( __file__ ).resolve().parent @@ -74,6 +76,28 @@ def read_json( path: pathlib.Path ) -> dict | 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 @@ -609,10 +633,7 @@ def main() -> int: parser.add_argument( "--output", required=True, type=pathlib.Path ) arguments = parser.parse_args() try: - arguments.output.parent.mkdir( parents=True, exist_ok=True ) - arguments.output.write_text( - render_html( arguments.artifact_dir ), encoding="utf-8" - ) + 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 diff --git a/scripts/tests/test_render_coverage_report_html.py b/scripts/tests/test_render_coverage_report_html.py index e6a1c23ff41..0631c1766ac 100644 --- a/scripts/tests/test_render_coverage_report_html.py +++ b/scripts/tests/test_render_coverage_report_html.py @@ -115,7 +115,12 @@ def test_report_is_a_single_self_contained_human_readable_page( self ): ( 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 ) From 682f206a51398eefb4b6df1331fd3fc931992091 Mon Sep 17 00:00:00 2001 From: "Victor A. P. Magri" Date: Mon, 17 Aug 2026 00:16:42 -0400 Subject: [PATCH 19/19] Minor improvements --- scripts/ci_build_and_test_in_container.sh | 4 ++ scripts/render_coverage_review_summary.py | 61 ++++++++++++++++++- .../test_render_coverage_review_summary.py | 28 +++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/scripts/ci_build_and_test_in_container.sh b/scripts/ci_build_and_test_in_container.sh index ee8b69b1e7c..196e1086716 100755 --- a/scripts/ci_build_and_test_in_container.sh +++ b/scripts/ci_build_and_test_in_container.sh @@ -675,6 +675,10 @@ if [[ "${LLVM_SOURCE_COVERAGE}" = true ]]; then 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 \ diff --git a/scripts/render_coverage_review_summary.py b/scripts/render_coverage_review_summary.py index 60558cbd1c9..c53fbbd4f24 100644 --- a/scripts/render_coverage_review_summary.py +++ b/scripts/render_coverage_review_summary.py @@ -23,6 +23,57 @@ "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: @@ -159,8 +210,9 @@ def main() -> int: 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( "--tests-result", required=True ) - parser.add_argument( "--tests-detail", required=True ) + parser.add_argument( "--console", action="store_true" ) + parser.add_argument( "--tests-result" ) + parser.add_argument( "--tests-detail" ) arguments = parser.parse_args() try: @@ -168,6 +220,11 @@ def main() -> int: 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 diff --git a/scripts/tests/test_render_coverage_review_summary.py b/scripts/tests/test_render_coverage_review_summary.py index ff34c296d93..4fa2b591794 100644 --- a/scripts/tests/test_render_coverage_review_summary.py +++ b/scripts/tests/test_render_coverage_review_summary.py @@ -56,6 +56,34 @@ def unavailable_report() -> dict: 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(),