From ea84002a72bca3dd0d46616b5ea4d45956e61be6 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 7 Jul 2026 18:03:35 -0700 Subject: [PATCH 1/8] Separate build and test concerns in CI workflow - Build jobs now run only Rust tests with code coverage (removed redundant Pester 'resources' group that was duplicated by pester jobs) - Pester jobs now collect code coverage by setting LLVM_PROFILE_FILE and using llvm-profdata/llvm-cov to export LCOV from instrumented binaries - Coverage report job merges all LCOV files (Rust tests + Pester tests) instead of picking only the first file found - Added Export-PesterCodeCoverageReport helper function for generating LCOV from profraw files using llvm tools directly - Added Merge-LcovFile helper function to consolidate multiple LCOV reports into a single merged file Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust.yml | 111 ++++++++++++++++++-- helpers.build.psm1 | 210 +++++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 7 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8337230f3..042735cab 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -55,7 +55,7 @@ jobs: - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources + run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -90,6 +90,16 @@ jobs: name: linux-bin - name: Expand build artifact run: tar -xvf bin.tar + - name: Install llvm-tools for coverage + run: rustup component add llvm-tools-preview + - name: Set coverage profile environment + id: coverage-env + run: |- + $profDir = Join-Path $PWD 'profraw-data' + New-Item -ItemType Directory -Path $profDir -Force | Out-Null + $profPattern = Join-Path $profDir '%m_%p.profraw' + "LLVM_PROFILE_FILE=$profPattern" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_ENV + "prof_dir=$profDir" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - name: Test ${{matrix.group}} run: |- $params = @{ @@ -99,6 +109,24 @@ jobs: Verbose = $true } ./build.ps1 @params -PesterTestGroup ${{matrix.group}} + - name: Generate coverage report + if: always() + run: |- + Import-Module ./helpers.build.psm1 -Force + $exportParams = @{ + BinDirectory = Join-Path $PWD 'bin' + ProfileDirectory = '${{ steps.coverage-env.outputs.prof_dir }}' + OutputPath = 'pester-lcov.info' + Verbose = $true + } + Export-PesterCodeCoverageReport @exportParams + - name: Upload coverage data + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-pester-${{matrix.group}}-coverage + path: pester-lcov.info + if-no-files-found: ignore macos-build: runs-on: macos-latest @@ -111,7 +139,7 @@ jobs: - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources + run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -146,6 +174,16 @@ jobs: name: macos-bin - name: Expand build artifact run: tar -xvf bin.tar + - name: Install llvm-tools for coverage + run: rustup component add llvm-tools-preview + - name: Set coverage profile environment + id: coverage-env + run: |- + $profDir = Join-Path $PWD 'profraw-data' + New-Item -ItemType Directory -Path $profDir -Force | Out-Null + $profPattern = Join-Path $profDir '%m_%p.profraw' + "LLVM_PROFILE_FILE=$profPattern" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_ENV + "prof_dir=$profDir" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - name: Test ${{matrix.group}} run: |- $params = @{ @@ -155,6 +193,24 @@ jobs: Verbose = $true } ./build.ps1 @params -PesterTestGroup ${{matrix.group}} + - name: Generate coverage report + if: always() + run: |- + Import-Module ./helpers.build.psm1 -Force + $exportParams = @{ + BinDirectory = Join-Path $PWD 'bin' + ProfileDirectory = '${{ steps.coverage-env.outputs.prof_dir }}' + OutputPath = 'pester-lcov.info' + Verbose = $true + } + Export-PesterCodeCoverageReport @exportParams + - name: Upload coverage data + if: always() + uses: actions/upload-artifact@v4 + with: + name: macos-pester-${{matrix.group}}-coverage + path: pester-lcov.info + if-no-files-found: ignore # Windows windows-build: @@ -171,7 +227,7 @@ jobs: - name: Build and test with code coverage id: rust-tests continue-on-error: true - run: ./build.ps1 -Clippy -Test -CodeCoverage -Verbose -PesterTestGroup resources + run: ./build.ps1 -Clippy -Test -CodeCoverage -ExcludePesterTests -Verbose - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -209,6 +265,16 @@ jobs: name: windows-bin - name: Expand build artifact run: tar -xvf bin.tar + - name: Install llvm-tools for coverage + run: rustup component add llvm-tools-preview + - name: Set coverage profile environment + id: coverage-env + run: |- + $profDir = Join-Path $PWD 'profraw-data' + New-Item -ItemType Directory -Path $profDir -Force | Out-Null + $profPattern = Join-Path $profDir '%m_%p.profraw' + "LLVM_PROFILE_FILE=$profPattern" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_ENV + "prof_dir=$profDir" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - name: Test ${{matrix.group}} run: |- $params = @{ @@ -218,6 +284,24 @@ jobs: Verbose = $true } ./build.ps1 @params -PesterTestGroup ${{matrix.group}} + - name: Generate coverage report + if: always() + run: |- + Import-Module ./helpers.build.psm1 -Force + $exportParams = @{ + BinDirectory = Join-Path $PWD 'bin' + ProfileDirectory = '${{ steps.coverage-env.outputs.prof_dir }}' + OutputPath = 'pester-lcov.info' + Verbose = $true + } + Export-PesterCodeCoverageReport @exportParams + - name: Upload coverage data + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-pester-${{matrix.group}}-coverage + path: pester-lcov.info + if-no-files-found: ignore coverage-report: if: github.event_name == 'pull_request' @@ -263,16 +347,29 @@ jobs: } "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - # Find the first available lcov.info from the platform coverage artifacts - $lcovFile = Get-ChildItem -Path 'coverage-data' -Filter 'lcov.info' -Recurse | Select-Object -First 1 - if (-not $lcovFile) { + # Find all available lcov.info files from coverage artifacts + $lcovFiles = Get-ChildItem -Path 'coverage-data' -Filter 'lcov.info' -Recurse + $pesterLcovFiles = Get-ChildItem -Path 'coverage-data' -Filter 'pester-lcov.info' -Recurse + $allLcovFiles = @($lcovFiles) + @($pesterLcovFiles) | Where-Object { $_ } + + if ($allLcovFiles.Count -eq 0) { Write-Warning 'No coverage data found from any platform.' "coverage_failed=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT return } "coverage_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - $report = Get-CodeCoverageReport -LcovPath $lcovFile.FullName -BaseSha $baseSha -HeadSha $headSha -Verbose + Write-Verbose -Verbose "Found $($allLcovFiles.Count) LCOV file(s) to merge" + + # Merge all LCOV files into a single consolidated report + $mergedLcovPath = Join-Path $PWD 'merged-lcov.info' + if ($allLcovFiles.Count -eq 1) { + Copy-Item -Path $allLcovFiles[0].FullName -Destination $mergedLcovPath + } else { + Merge-LcovFile -Path ($allLcovFiles | ForEach-Object { $_.FullName }) -OutputPath $mergedLcovPath -Verbose + } + + $report = Get-CodeCoverageReport -LcovPath $mergedLcovPath -BaseSha $baseSha -HeadSha $headSha -Verbose "percentage=$($report.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT "covered=$($report.CoveredLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 1a62b8c51..f3ec3f491 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2017,6 +2017,216 @@ function Export-CodeCoverageReport { } } +function Export-PesterCodeCoverageReport { + <# + .SYNOPSIS + Generates an LCOV code coverage report from profraw files produced by instrumented binaries. + + .DESCRIPTION + Uses llvm-profdata and llvm-cov directly (from the rustup llvm-tools-preview component) + to merge raw profile data and export an LCOV report. This is used by Pester test jobs + that run instrumented binaries outside of the cargo build environment. + + .PARAMETER BinDirectory + Path to the directory containing instrumented binaries (e.g., bin/). + + .PARAMETER ProfileDirectory + Path to the directory containing .profraw files produced by instrumented binaries. + + .PARAMETER OutputPath + The file path where the LCOV report will be written. + + .PARAMETER SourceDirectory + Optional path to the source directory for source-level mapping. Defaults to the + repository root. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$BinDirectory, + + [Parameter(Mandatory)] + [string]$ProfileDirectory, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter()] + [string]$SourceDirectory = $PSScriptRoot + ) + + process { + # Find the llvm tools from rustup + $toolchainPath = & rustc --print sysroot 2>$null + if (-not $toolchainPath) { + throw 'Could not determine Rust toolchain sysroot. Ensure rustc is installed.' + } + + $llvmBinDir = Join-Path $toolchainPath 'lib' 'rustlib' (& rustc -vV | + Select-String 'host: (.+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }) 'bin' + + $llvmProfdata = Join-Path $llvmBinDir 'llvm-profdata' + $llvmCov = Join-Path $llvmBinDir 'llvm-cov' + + if ($IsWindows) { + $llvmProfdata += '.exe' + $llvmCov += '.exe' + } + + if (-not (Test-Path $llvmProfdata)) { + throw "llvm-profdata not found at '$llvmProfdata'. Ensure llvm-tools-preview is installed via: rustup component add llvm-tools-preview" + } + + # Find all profraw files + $profrawFiles = Get-ChildItem -Path $ProfileDirectory -Filter '*.profraw' -Recurse + if ($profrawFiles.Count -eq 0) { + Write-Warning "No .profraw files found in '$ProfileDirectory'. Coverage report will be empty." + return + } + Write-Verbose -Verbose "Found $($profrawFiles.Count) profraw file(s)" + + # Merge profraw files into a single profdata file + $profdataPath = Join-Path $ProfileDirectory 'merged.profdata' + $mergeArgs = @('merge', '-sparse') + $mergeArgs += $profrawFiles.FullName + $mergeArgs += @('-o', $profdataPath) + + Write-Verbose -Verbose "Merging profraw files into: $profdataPath" + & $llvmProfdata @mergeArgs + if ($LASTEXITCODE -ne 0) { + throw "llvm-profdata merge failed with exit code $LASTEXITCODE" + } + + # Find all executable binaries in the bin directory + $binaries = if ($IsWindows) { + Get-ChildItem -Path $BinDirectory -Filter '*.exe' -File + } else { + Get-ChildItem -Path $BinDirectory -File | Where-Object { + # On Unix, check if file is executable + (& test -x $_.FullName) -and $_.Extension -notin @('.pdb', '.d', '.ps1', '.psm1', '.psd1', '.json', '.yaml', '.yml', '.txt', '.md') + } + } + + if ($binaries.Count -eq 0) { + Write-Warning "No executable binaries found in '$BinDirectory'. Cannot generate coverage report." + return + } + Write-Verbose -Verbose "Using $($binaries.Count) binary file(s) for coverage export" + + # Build llvm-cov export arguments + # First binary is the primary, additional are specified via -object + $covArgs = @( + 'export' + '-format=lcov' + "-instr-profile=$profdataPath" + '--ignore-filename-regex=\.cargo|rustc' + ) + + $covArgs += $binaries[0].FullName + for ($i = 1; $i -lt $binaries.Count; $i++) { + $covArgs += @('-object', $binaries[$i].FullName) + } + + Write-Verbose -Verbose "Exporting LCOV report to: $OutputPath" + & $llvmCov @covArgs > $OutputPath 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warning "llvm-cov export returned exit code $LASTEXITCODE. Report may be incomplete." + } + + if (Test-Path $OutputPath) { + $fileSize = (Get-Item $OutputPath).Length + Write-Verbose -Verbose "Code coverage report written to: $OutputPath ($fileSize bytes)" + } else { + Write-Warning "Coverage report was not generated at: $OutputPath" + } + } +} + +function Merge-LcovFile { + <# + .SYNOPSIS + Merges multiple LCOV files into a single consolidated report. + + .DESCRIPTION + Reads multiple LCOV-format coverage files and merges them by combining line hit + counts for matching source files. When the same line appears in multiple reports, + the hit counts are summed. + + .PARAMETER Path + Array of paths to LCOV files to merge. + + .PARAMETER OutputPath + The file path where the merged LCOV report will be written. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$Path, + + [Parameter(Mandatory)] + [string]$OutputPath + ) + + process { + # Structure: $coverage[sourceFile][lineNumber] = hitCount + $coverage = @{} + $functionData = @{} + + foreach ($lcovPath in $Path) { + if (-not (Test-Path $lcovPath)) { + Write-Verbose "Skipping missing LCOV file: $lcovPath" + continue + } + + $currentFile = $null + foreach ($line in Get-Content -Path $lcovPath) { + if ($line -match '^SF:(.+)$') { + $currentFile = $Matches[1] + if (-not $coverage.ContainsKey($currentFile)) { + $coverage[$currentFile] = @{} + $functionData[$currentFile] = [System.Collections.Generic.List[string]]::new() + } + } elseif ($line -match '^DA:(\d+),(\d+)') { + $lineNum = [int]$Matches[1] + $hits = [int]$Matches[2] + if ($currentFile -and $coverage.ContainsKey($currentFile)) { + if ($coverage[$currentFile].ContainsKey($lineNum)) { + $coverage[$currentFile][$lineNum] += $hits + } else { + $coverage[$currentFile][$lineNum] = $hits + } + } + } elseif ($line -match '^(FN|FNDA|FNF|FNH):' -and $currentFile) { + $functionData[$currentFile].Add($line) + } + } + } + + # Write merged output + $output = [System.Text.StringBuilder]::new() + foreach ($file in $coverage.Keys | Sort-Object) { + [void]$output.AppendLine("SF:$file") + foreach ($fn in $functionData[$file]) { + [void]$output.AppendLine($fn) + } + $lineCount = 0 + $hitCount = 0 + foreach ($lineNum in $coverage[$file].Keys | Sort-Object) { + $hits = $coverage[$file][$lineNum] + [void]$output.AppendLine("DA:$lineNum,$hits") + $lineCount++ + if ($hits -gt 0) { $hitCount++ } + } + [void]$output.AppendLine("LF:$lineCount") + [void]$output.AppendLine("LH:$hitCount") + [void]$output.AppendLine('end_of_record') + } + + Set-Content -Path $OutputPath -Value $output.ToString() -NoNewline + Write-Verbose -Verbose "Merged $($Path.Count) LCOV file(s) into: $OutputPath" + } +} + function Show-CodeCoverageReport { <# .SYNOPSIS From 5bb5e4965fdd39ecc0cda1716c0fa4e5881694ba Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 7 Jul 2026 18:07:50 -0700 Subject: [PATCH 2/8] Use temp dirs for coverage profraw data and add full codebase report - Pester jobs now write profraw files to a unique temp directory instead of the workspace, with cleanup after LCOV generation - Coverage report now includes both changed-code coverage (existing) and full codebase coverage (new) in the PR comment - Added Get-FullCodeCoverageReport helper that computes overall line coverage across all instrumented source files Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust.yml | 72 ++++++++++++++++++++++++++++++-------- helpers.build.psm1 | 71 +++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 042735cab..abd5595a1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -95,11 +95,10 @@ jobs: - name: Set coverage profile environment id: coverage-env run: |- - $profDir = Join-Path $PWD 'profraw-data' - New-Item -ItemType Directory -Path $profDir -Force | Out-Null - $profPattern = Join-Path $profDir '%m_%p.profraw' + $profDir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "dsc-pester-cov-$([System.Guid]::NewGuid().ToString('N'))") -Force + $profPattern = Join-Path $profDir.FullName '%m_%p.profraw' "LLVM_PROFILE_FILE=$profPattern" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_ENV - "prof_dir=$profDir" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "prof_dir=$($profDir.FullName)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - name: Test ${{matrix.group}} run: |- $params = @{ @@ -120,6 +119,14 @@ jobs: Verbose = $true } Export-PesterCodeCoverageReport @exportParams + - name: Clean up coverage temp data + if: always() + run: |- + $profDir = '${{ steps.coverage-env.outputs.prof_dir }}' + if (Test-Path $profDir) { + Remove-Item -Path $profDir -Recurse -Force + Write-Host "Cleaned up temp coverage data: $profDir" + } - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -179,11 +186,10 @@ jobs: - name: Set coverage profile environment id: coverage-env run: |- - $profDir = Join-Path $PWD 'profraw-data' - New-Item -ItemType Directory -Path $profDir -Force | Out-Null - $profPattern = Join-Path $profDir '%m_%p.profraw' + $profDir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "dsc-pester-cov-$([System.Guid]::NewGuid().ToString('N'))") -Force + $profPattern = Join-Path $profDir.FullName '%m_%p.profraw' "LLVM_PROFILE_FILE=$profPattern" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_ENV - "prof_dir=$profDir" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "prof_dir=$($profDir.FullName)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - name: Test ${{matrix.group}} run: |- $params = @{ @@ -204,6 +210,14 @@ jobs: Verbose = $true } Export-PesterCodeCoverageReport @exportParams + - name: Clean up coverage temp data + if: always() + run: |- + $profDir = '${{ steps.coverage-env.outputs.prof_dir }}' + if (Test-Path $profDir) { + Remove-Item -Path $profDir -Recurse -Force + Write-Host "Cleaned up temp coverage data: $profDir" + } - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -270,11 +284,10 @@ jobs: - name: Set coverage profile environment id: coverage-env run: |- - $profDir = Join-Path $PWD 'profraw-data' - New-Item -ItemType Directory -Path $profDir -Force | Out-Null - $profPattern = Join-Path $profDir '%m_%p.profraw' + $profDir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "dsc-pester-cov-$([System.Guid]::NewGuid().ToString('N'))") -Force + $profPattern = Join-Path $profDir.FullName '%m_%p.profraw' "LLVM_PROFILE_FILE=$profPattern" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_ENV - "prof_dir=$profDir" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "prof_dir=$($profDir.FullName)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - name: Test ${{matrix.group}} run: |- $params = @{ @@ -295,6 +308,14 @@ jobs: Verbose = $true } Export-PesterCodeCoverageReport @exportParams + - name: Clean up coverage temp data + if: always() + run: |- + $profDir = '${{ steps.coverage-env.outputs.prof_dir }}' + if (Test-Path $profDir) { + Remove-Item -Path $profDir -Recurse -Force + Write-Host "Cleaned up temp coverage data: $profDir" + } - name: Upload coverage data if: always() uses: actions/upload-artifact@v4 @@ -369,6 +390,7 @@ jobs: Merge-LcovFile -Path ($allLcovFiles | ForEach-Object { $_.FullName }) -OutputPath $mergedLcovPath -Verbose } + # Changed-code coverage report $report = Get-CodeCoverageReport -LcovPath $mergedLcovPath -BaseSha $baseSha -HeadSha $headSha -Verbose "percentage=$($report.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT @@ -377,6 +399,15 @@ jobs: "emoji=$($report.Emoji)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT "label=$($report.Label)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + # Full codebase coverage report + $fullReport = Get-FullCodeCoverageReport -LcovPath $mergedLcovPath -Verbose + + "full_percentage=$($fullReport.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "full_covered=$($fullReport.CoveredLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "full_total=$($fullReport.TotalLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "full_emoji=$($fullReport.Emoji)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "full_label=$($fullReport.Label)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + - name: Post coverage comment if: >- steps.coverage.outputs.has_rust_changes == 'true' @@ -388,7 +419,9 @@ jobs: message: | ## ${{ steps.coverage.outputs.emoji }} Code Coverage Report - **Changed code coverage: ${{ steps.coverage.outputs.percentage }}%** (${{ steps.coverage.outputs.label }}) + ### Changed Code Coverage + + **${{ steps.coverage.outputs.percentage }}%** (${{ steps.coverage.outputs.label }}) | Metric | Value | |--------|-------| @@ -396,7 +429,18 @@ jobs: | Lines covered by tests | ${{ steps.coverage.outputs.covered }} | | Coverage percentage | ${{ steps.coverage.outputs.percentage }}% | - > Coverage is measured only on changed Rust code in this PR. + ### ${{ steps.coverage.outputs.full_emoji }} Full Codebase Coverage + + **${{ steps.coverage.outputs.full_percentage }}%** (${{ steps.coverage.outputs.full_label }}) + + | Metric | Value | + |--------|-------| + | Total executable lines | ${{ steps.coverage.outputs.full_total }} | + | Lines covered by tests | ${{ steps.coverage.outputs.full_covered }} | + | Coverage percentage | ${{ steps.coverage.outputs.full_percentage }}% | + + > Changed code coverage measures only Rust lines added/modified in this PR. + > Full codebase coverage measures all instrumented Rust lines across the project. - name: Post coverage comment (report failed) if: >- diff --git a/helpers.build.psm1 b/helpers.build.psm1 index f3ec3f491..9de0dc2cb 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2455,6 +2455,77 @@ function Get-CodeCoverageReport { } } } +function Get-FullCodeCoverageReport { + <# + .SYNOPSIS + Computes overall code coverage statistics from an LCOV file across the entire codebase. + + .DESCRIPTION + Parses an LCOV file and computes total line coverage across all source files, + providing a full-codebase coverage percentage regardless of what changed in a PR. + + .PARAMETER LcovPath + Path to the LCOV coverage report file. + + .OUTPUTS + PSCustomObject with properties: Percentage, CoveredLines, TotalLines, Emoji, Label + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$LcovPath + ) + + process { + if (-not (Test-Path $LcovPath)) { + throw "LCOV file not found at '$LcovPath'" + } + + $totalLines = 0 + $coveredLines = 0 + + foreach ($line in Get-Content -Path $LcovPath) { + if ($line -match '^DA:(\d+),(\d+)') { + $rawHit = [decimal]$Matches[2] + # LLVM emits sentinel values near UInt64.MaxValue for uninstrumented lines + $hitCount = if ($rawHit -gt [long]::MaxValue) { 0 } else { [long]$rawHit } + $totalLines++ + if ($hitCount -gt 0) { + $coveredLines++ + } + } + } + + if ($totalLines -eq 0) { + $percentage = 0 + } else { + $percentage = [int][math]::Floor($coveredLines * 100 / $totalLines) + } + + $emoji, $label = if ($percentage -ge 90) { + ':green_circle:', 'excellent' + } elseif ($percentage -ge 80) { + ':large_blue_circle:', 'good' + } elseif ($percentage -ge 70) { + ':yellow_circle:', 'acceptable' + } elseif ($percentage -ge 60) { + ':orange_circle:', 'needs improvement' + } else { + ':red_circle:', 'low' + } + + Write-Verbose -Verbose "Full codebase coverage: $percentage% ($coveredLines/$totalLines lines)" + + [PSCustomObject]@{ + Percentage = $percentage + CoveredLines = $coveredLines + TotalLines = $totalLines + Emoji = $emoji + Label = $label + } + } +} + #endregion Code coverage functions #region Test project functions From b4b2f88f1f16be758533d9b45d6ffbd51d450897 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 7 Jul 2026 18:18:48 -0700 Subject: [PATCH 3/8] Address PR review feedback for Export-PesterCodeCoverageReport - Fix Join-Path with multiple segments: use [System.IO.Path]::Combine() and validate host triple parse result - Add Test-Path check for llvm-cov (not just llvm-profdata) - Wrap Get-ChildItem for profraw files in @() with -ErrorAction SilentlyContinue to handle null/empty reliably - Fix Unix executable detection: use UnixMode property instead of shelling out to 'test -x' which returns no output in PowerShell - Wrap binaries result in @() to ensure array even when null/single - Remove unused SourceDirectory parameter and its documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 9de0dc2cb..621c18831 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2035,10 +2035,6 @@ function Export-PesterCodeCoverageReport { .PARAMETER OutputPath The file path where the LCOV report will be written. - - .PARAMETER SourceDirectory - Optional path to the source directory for source-level mapping. Defaults to the - repository root. #> [CmdletBinding()] param( @@ -2049,10 +2045,7 @@ function Export-PesterCodeCoverageReport { [string]$ProfileDirectory, [Parameter(Mandatory)] - [string]$OutputPath, - - [Parameter()] - [string]$SourceDirectory = $PSScriptRoot + [string]$OutputPath ) process { @@ -2062,8 +2055,13 @@ function Export-PesterCodeCoverageReport { throw 'Could not determine Rust toolchain sysroot. Ensure rustc is installed.' } - $llvmBinDir = Join-Path $toolchainPath 'lib' 'rustlib' (& rustc -vV | - Select-String 'host: (.+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }) 'bin' + $hostTriple = & rustc -vV | + Select-String 'host: (.+)' | ForEach-Object { $_.Matches[0].Groups[1].Value } + if (-not $hostTriple) { + throw 'Could not determine Rust host triple from rustc -vV output.' + } + + $llvmBinDir = [System.IO.Path]::Combine($toolchainPath, 'lib', 'rustlib', $hostTriple, 'bin') $llvmProfdata = Join-Path $llvmBinDir 'llvm-profdata' $llvmCov = Join-Path $llvmBinDir 'llvm-cov' @@ -2076,9 +2074,12 @@ function Export-PesterCodeCoverageReport { if (-not (Test-Path $llvmProfdata)) { throw "llvm-profdata not found at '$llvmProfdata'. Ensure llvm-tools-preview is installed via: rustup component add llvm-tools-preview" } + if (-not (Test-Path $llvmCov)) { + throw "llvm-cov not found at '$llvmCov'. Ensure llvm-tools-preview is installed via: rustup component add llvm-tools-preview" + } # Find all profraw files - $profrawFiles = Get-ChildItem -Path $ProfileDirectory -Filter '*.profraw' -Recurse + $profrawFiles = @(Get-ChildItem -Path $ProfileDirectory -Filter '*.profraw' -Recurse -ErrorAction SilentlyContinue) if ($profrawFiles.Count -eq 0) { Write-Warning "No .profraw files found in '$ProfileDirectory'. Coverage report will be empty." return @@ -2098,14 +2099,17 @@ function Export-PesterCodeCoverageReport { } # Find all executable binaries in the bin directory - $binaries = if ($IsWindows) { - Get-ChildItem -Path $BinDirectory -Filter '*.exe' -File - } else { - Get-ChildItem -Path $BinDirectory -File | Where-Object { - # On Unix, check if file is executable - (& test -x $_.FullName) -and $_.Extension -notin @('.pdb', '.d', '.ps1', '.psm1', '.psd1', '.json', '.yaml', '.yml', '.txt', '.md') + $nonBinaryExtensions = @('.pdb', '.d', '.ps1', '.psm1', '.psd1', '.json', '.yaml', '.yml', '.txt', '.md') + $binaries = @( + if ($IsWindows) { + Get-ChildItem -Path $BinDirectory -Filter '*.exe' -File + } else { + Get-ChildItem -Path $BinDirectory -File | Where-Object { + $_.Extension -notin $nonBinaryExtensions -and + ($_.UnixMode -and $_.UnixMode -match 'x') + } } - } + ) if ($binaries.Count -eq 0) { Write-Warning "No executable binaries found in '$BinDirectory'. Cannot generate coverage report." From 2cee4916712fbb1c1bd51fb636c5f43cb967035c Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 7 Jul 2026 18:25:06 -0700 Subject: [PATCH 4/8] Fix Merge-LcovFile sentinel values, Mode-based exec check, drop FN records - Parse DA hit counts as [decimal] and clamp values above [long]::MaxValue to 0 (matching Get-CodeCoverageReport behavior for LLVM sentinels) - Use portable Mode property instead of UnixMode for executable detection - Remove function-level LCOV records (FN/FNDA/FNF/FNH) from merged output since correct aggregation requires more than simple concatenation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index 621c18831..f8eca9f80 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2106,7 +2106,7 @@ function Export-PesterCodeCoverageReport { } else { Get-ChildItem -Path $BinDirectory -File | Where-Object { $_.Extension -notin $nonBinaryExtensions -and - ($_.UnixMode -and $_.UnixMode -match 'x') + ($_.Mode -match 'x') } } ) @@ -2174,7 +2174,6 @@ function Merge-LcovFile { process { # Structure: $coverage[sourceFile][lineNumber] = hitCount $coverage = @{} - $functionData = @{} foreach ($lcovPath in $Path) { if (-not (Test-Path $lcovPath)) { @@ -2188,11 +2187,12 @@ function Merge-LcovFile { $currentFile = $Matches[1] if (-not $coverage.ContainsKey($currentFile)) { $coverage[$currentFile] = @{} - $functionData[$currentFile] = [System.Collections.Generic.List[string]]::new() } } elseif ($line -match '^DA:(\d+),(\d+)') { $lineNum = [int]$Matches[1] - $hits = [int]$Matches[2] + # LLVM emits sentinel values near UInt64.MaxValue for uninstrumented lines + $rawHit = [decimal]$Matches[2] + $hits = if ($rawHit -gt [long]::MaxValue) { [long]0 } else { [long]$rawHit } if ($currentFile -and $coverage.ContainsKey($currentFile)) { if ($coverage[$currentFile].ContainsKey($lineNum)) { $coverage[$currentFile][$lineNum] += $hits @@ -2200,19 +2200,15 @@ function Merge-LcovFile { $coverage[$currentFile][$lineNum] = $hits } } - } elseif ($line -match '^(FN|FNDA|FNF|FNH):' -and $currentFile) { - $functionData[$currentFile].Add($line) } } } - # Write merged output + # Write merged output (line-level data only; function records are omitted + # because merging them correctly requires aggregation logic beyond summing) $output = [System.Text.StringBuilder]::new() foreach ($file in $coverage.Keys | Sort-Object) { [void]$output.AppendLine("SF:$file") - foreach ($fn in $functionData[$file]) { - [void]$output.AppendLine($fn) - } $lineCount = 0 $hitCount = 0 foreach ($lineNum in $coverage[$file].Keys | Sort-Object) { From 332c80a8b3aea2073205017292198ce4b21f44d7 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 7 Jul 2026 19:38:20 -0700 Subject: [PATCH 5/8] Use response file for llvm-profdata merge to avoid Windows arg limit When many profraw files are produced (1600+), passing all paths as command-line arguments exceeds Windows' limit. Write the file list to a response file and pass it via the @file syntax instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- helpers.build.psm1 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/helpers.build.psm1 b/helpers.build.psm1 index f8eca9f80..de49f6477 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2088,12 +2088,13 @@ function Export-PesterCodeCoverageReport { # Merge profraw files into a single profdata file $profdataPath = Join-Path $ProfileDirectory 'merged.profdata' - $mergeArgs = @('merge', '-sparse') - $mergeArgs += $profrawFiles.FullName - $mergeArgs += @('-o', $profdataPath) - Write-Verbose -Verbose "Merging profraw files into: $profdataPath" - & $llvmProfdata @mergeArgs + # Write file list to a response file to avoid command-line length limits on Windows + $responseFile = Join-Path $ProfileDirectory 'profraw-files.txt' + $profrawFiles.FullName | Set-Content -Path $responseFile -Encoding utf8 + + Write-Verbose -Verbose "Merging profraw files into: $profdataPath (via response file)" + & $llvmProfdata merge -sparse "@$responseFile" -o $profdataPath if ($LASTEXITCODE -ne 0) { throw "llvm-profdata merge failed with exit code $LASTEXITCODE" } From c1f9924a63196e5f3717d24630ae93311971c346 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 7 Jul 2026 20:12:21 -0700 Subject: [PATCH 6/8] change -Pending to -Skip --- .../powershell/Tests/powershellgroup.resource.tests.ps1 | 9 ++++++--- dsc/tests/dsc_set.tests.ps1 | 3 +-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 b/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 index 97bcc5897..6dfbe5b95 100644 --- a/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 +++ b/adapters/powershell/Tests/powershellgroup.resource.tests.ps1 @@ -87,7 +87,8 @@ Describe 'PowerShell adapter resource tests' { $res.changedProperties | Should -BeNullOrEmpty } - It 'Export works on PS class-based resource' -Pending { + # pending test + It 'Export works on PS class-based resource' -Skip { $r = dsc resource export -r TestClassResource/TestClassResource $LASTEXITCODE | Should -Be 0 @@ -104,7 +105,8 @@ Describe 'PowerShell adapter resource tests' { } } - It 'Get --all works on PS class-based resource' -Pending { + # pending test + It 'Get --all works on PS class-based resource' -Skip { $r = dsc resource get --all -r TestClassResource/TestClassResource 2>$null $LASTEXITCODE | Should -Be 0 @@ -326,7 +328,8 @@ Describe 'PowerShell adapter resource tests' { } } - It 'Dsc can process large resource output' -Pending { + # pending test + It 'Dsc can process large resource output' -Skip { try { $env:TestClassResourceResultCount = 5000 # with sync resource invocations this was not possible diff --git a/dsc/tests/dsc_set.tests.ps1 b/dsc/tests/dsc_set.tests.ps1 index 05d6ef115..b20e2ebb3 100644 --- a/dsc/tests/dsc_set.tests.ps1 +++ b/dsc/tests/dsc_set.tests.ps1 @@ -77,8 +77,7 @@ Describe 'resource set tests' { } } - # test pending changes in engine to call delete if _exist is not handled directly - It 'can set and remove a registry value' -Pending { + It 'can set and remove a registry value' -Skip:(!$IsWindows) { $json = @' { "keyPath": "HKCU\\1\\2\\3", From 26262f14be921b2c4c632a8b69c61ddad9947eb8 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 7 Jul 2026 21:53:32 -0700 Subject: [PATCH 7/8] Always show full codebase coverage even when no Rust files changed Restructured the coverage report logic so the full codebase report is computed and displayed regardless of whether Rust files were modified in the PR. The changed-code section is still conditional on Rust changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust.yml | 56 ++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index abd5595a1..96fee8217 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -351,23 +351,12 @@ jobs: $headSha = '${{ github.event.pull_request.head.sha }}' # Compute the merge-base to get an accurate diff of only PR changes. - # Using base.sha directly is unreliable after rebases or when the base - # branch has advanced, since it points to the tip of the base branch at - # event time rather than the common ancestor. $mergeBase = git merge-base $baseSha $headSha 2>$null if ($LASTEXITCODE -eq 0 -and $mergeBase) { Write-Verbose -Verbose "Using merge-base $mergeBase (base=$baseSha, head=$headSha)" $baseSha = $mergeBase } - # Determine if any Rust files changed from git diff - $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha..$headSha" -- '*.rs' | Where-Object { $_ } - if (-not $changedFiles) { - "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - return - } - "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - # Find all available lcov.info files from coverage artifacts $lcovFiles = Get-ChildItem -Path 'coverage-data' -Filter 'lcov.info' -Recurse $pesterLcovFiles = Get-ChildItem -Path 'coverage-data' -Filter 'pester-lcov.info' -Recurse @@ -390,16 +379,7 @@ jobs: Merge-LcovFile -Path ($allLcovFiles | ForEach-Object { $_.FullName }) -OutputPath $mergedLcovPath -Verbose } - # Changed-code coverage report - $report = Get-CodeCoverageReport -LcovPath $mergedLcovPath -BaseSha $baseSha -HeadSha $headSha -Verbose - - "percentage=$($report.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "covered=$($report.CoveredLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "total=$($report.TotalLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "emoji=$($report.Emoji)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - "label=$($report.Label)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT - - # Full codebase coverage report + # Full codebase coverage report (always computed) $fullReport = Get-FullCodeCoverageReport -LcovPath $mergedLcovPath -Verbose "full_percentage=$($fullReport.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT @@ -408,6 +388,22 @@ jobs: "full_emoji=$($fullReport.Emoji)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT "full_label=$($fullReport.Label)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + # Changed-code coverage report (only when Rust files were modified) + $changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha..$headSha" -- '*.rs' | Where-Object { $_ } + if (-not $changedFiles) { + "has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + return + } + "has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + + $report = Get-CodeCoverageReport -LcovPath $mergedLcovPath -BaseSha $baseSha -HeadSha $headSha -Verbose + + "percentage=$($report.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "covered=$($report.CoveredLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "total=$($report.TotalLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "emoji=$($report.Emoji)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + "label=$($report.Label)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT + - name: Post coverage comment if: >- steps.coverage.outputs.has_rust_changes == 'true' @@ -444,8 +440,7 @@ jobs: - name: Post coverage comment (report failed) if: >- - steps.coverage.outputs.has_rust_changes == 'true' - && steps.coverage.outputs.coverage_failed == 'true' + steps.coverage.outputs.coverage_failed == 'true' && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v2 with: @@ -459,6 +454,7 @@ jobs: - name: Post no-changes comment if: >- steps.coverage.outputs.has_rust_changes == 'false' + && steps.coverage.outputs.coverage_failed != 'true' && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v2 with: @@ -466,7 +462,19 @@ jobs: message: | ## Code Coverage Report - No Rust files were changed in this PR. Coverage analysis skipped. + No Rust files were changed in this PR. + + ### ${{ steps.coverage.outputs.full_emoji }} Full Codebase Coverage + + **${{ steps.coverage.outputs.full_percentage }}%** (${{ steps.coverage.outputs.full_label }}) + + | Metric | Value | + |--------|-------| + | Total executable lines | ${{ steps.coverage.outputs.full_total }} | + | Lines covered by tests | ${{ steps.coverage.outputs.full_covered }} | + | Coverage percentage | ${{ steps.coverage.outputs.full_percentage }}% | + + > Full codebase coverage measures all instrumented Rust lines across the project. - name: Fail if coverage is below 70% if: >- From 687fa4f97a3f6c18688bb5b5b5a9fea40dd891c1 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 8 Jul 2026 07:45:20 -0700 Subject: [PATCH 8/8] Add full codebase coverage report and line-level detail to local builds When running ./build.ps1 -CodeCoverage locally, the output now includes: - Full codebase coverage summary (always shown) - File-by-file breakdown with -CodeCoverageShowFiles - Uncovered line display with -CodeCoverageShowUncoveredLines - Configurable threshold and top-N file limits New helper functions: - Get-FullCodeCoverageDetail: parses LCOV into per-file coverage objects - Show-FullCodeCoverageReport: colorized file/line coverage display Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 60 ++++++++++++-- helpers.build.psm1 | 199 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 5 deletions(-) diff --git a/build.ps1 b/build.ps1 index 84cf5323e..a3adf7890 100755 --- a/build.ps1 +++ b/build.ps1 @@ -66,6 +66,22 @@ using module ./helpers.build.psm1 The head commit SHA to compare when detecting changed Rust files. When specified along with `-CodeCoverageBaseSha`, coverage is skipped if no `.rs` files were modified. + .PARAMETER CodeCoverageShowFiles + When specified, displays a file-by-file coverage breakdown sorted by coverage percentage + (lowest first) after the summary. Use with `-CodeCoverage`. + + .PARAMETER CodeCoverageShowUncoveredLines + When specified, displays the actual uncovered source lines for files below the threshold + set by `-CodeCoverageUncoveredThreshold`. Implies file-level display. Use with `-CodeCoverage`. + + .PARAMETER CodeCoverageUncoveredThreshold + Files with coverage at or below this percentage will have uncovered lines shown when + `-CodeCoverageShowUncoveredLines` is specified. Defaults to 80. + + .PARAMETER CodeCoverageTopFiles + Limits the file-by-file display to the N files with the lowest coverage. Defaults to 0 + (show all files). Use with `-CodeCoverageShowFiles` or `-CodeCoverageShowUncoveredLines`. + .PARAMETER GetPackageVersion Short circuits the build to return the current version of the DSC CLI crate. @@ -113,6 +129,10 @@ param( [string]$CodeCoverageOutputPath = (Join-Path $PSScriptRoot 'lcov.info'), [string]$CodeCoverageBaseSha, [string]$CodeCoverageHeadSha, + [switch]$CodeCoverageShowFiles, + [switch]$CodeCoverageShowUncoveredLines, + [int]$CodeCoverageUncoveredThreshold = 80, + [int]$CodeCoverageTopFiles = 0, [string[]]$Project, [switch]$ExcludeRustTests, [string]$RustTestFilter, @@ -366,7 +386,30 @@ process { Write-BuildProgress @progressParams -Status 'Restoring environment variables' Reset-LlvmCovEnvironment -PriorValues $priorLlvmCovEnv @VerboseParam - # Determine base and head SHAs for analysis + # Full codebase coverage summary + $progressParams.Activity = 'Analyzing code coverage' + Write-BuildProgress @progressParams -Status 'Computing full codebase coverage' + $fullReport = Get-FullCodeCoverageReport -LcovPath $CodeCoverageOutputPath @VerboseParam + Write-Host "" + Write-Host "$($PSStyle.Bold)Full Codebase Coverage: $($fullReport.Percentage)% ($($fullReport.Label))$($PSStyle.BoldOff)" + Write-Host " Total executable lines: $($fullReport.TotalLines) | Lines covered: $($fullReport.CoveredLines)" + + # File-by-file breakdown and line-level detail + if ($CodeCoverageShowFiles -or $CodeCoverageShowUncoveredLines) { + $showParams = @{ + LcovPath = $CodeCoverageOutputPath + } + if ($CodeCoverageShowUncoveredLines) { + $showParams.ShowUncoveredLines = $true + $showParams.UncoveredThreshold = $CodeCoverageUncoveredThreshold + } + if ($CodeCoverageTopFiles -gt 0) { + $showParams.Top = $CodeCoverageTopFiles + } + Show-FullCodeCoverageReport @showParams + } + + # Determine base and head SHAs for changed-code analysis $baseSha = $CodeCoverageBaseSha $headSha = $CodeCoverageHeadSha @@ -388,12 +431,19 @@ process { } if ($baseSha -and $headSha) { - $progressParams.Activity = 'Analyzing code coverage' - Write-BuildProgress @progressParams + Write-BuildProgress @progressParams -Status 'Analyzing changed code coverage' $report = Get-CodeCoverageReport -LcovPath $CodeCoverageOutputPath -BaseSha $baseSha -HeadSha $headSha @VerboseParam - Write-Host "$($report.Emoji) Changed code coverage: $($report.Percentage)% ($($report.Label))" - Write-Host " Lines analyzed: $($report.TotalLines) | Lines covered: $($report.CoveredLines)" + Write-Host "" + Write-Host "$($PSStyle.Bold)Changed Code Coverage: $($report.Percentage)% ($($report.Label))$($PSStyle.BoldOff)" + Write-Host " Changed lines analyzed: $($report.TotalLines) | Lines covered: $($report.CoveredLines)" + } else { + Write-Host "" + Write-Host "No base branch detected for changed-code analysis. Showing full report only." + Write-Host "Use -CodeCoverageBaseSha and -CodeCoverageHeadSha for changed-code coverage." } + + Write-Host "" + Write-Host "LCOV report: $CodeCoverageOutputPath" } #endregion Code coverage report diff --git a/helpers.build.psm1 b/helpers.build.psm1 index de49f6477..a52016110 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2527,6 +2527,205 @@ function Get-FullCodeCoverageReport { } } +function Get-FullCodeCoverageDetail { + <# + .SYNOPSIS + Parses an LCOV file and returns per-file coverage details for the entire codebase. + + .DESCRIPTION + Reads an LCOV coverage report and returns an array of objects, one per source file, + containing the file path, coverage percentage, line counts, and a map of uncovered + line numbers. This enables file-by-file and line-by-line coverage inspection. + + .PARAMETER LcovPath + Path to the LCOV coverage report file. + + .PARAMETER MinimumLines + Minimum number of executable lines a file must have to be included in output. + Defaults to 1 (include all files with any instrumented lines). + + .OUTPUTS + Array of PSCustomObject with: File, Percentage, CoveredLines, TotalLines, UncoveredLines + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$LcovPath, + + [Parameter()] + [int]$MinimumLines = 1 + ) + + process { + if (-not (Test-Path $LcovPath)) { + throw "LCOV file not found at '$LcovPath'" + } + + $fileData = @{} + $currentFile = $null + + foreach ($line in Get-Content -Path $LcovPath) { + if ($line -match '^SF:(.+)$') { + $currentFile = $Matches[1] + $fileData[$currentFile] = @{ Lines = @{} } + } elseif ($line -match '^DA:(\d+),(\d+)' -and $currentFile) { + $lineNum = [int]$Matches[1] + $rawHit = [decimal]$Matches[2] + $hitCount = if ($rawHit -gt [long]::MaxValue) { [long]0 } else { [long]$rawHit } + $fileData[$currentFile].Lines[$lineNum] = $hitCount + } elseif ($line -eq 'end_of_record') { + $currentFile = $null + } + } + + $results = foreach ($file in $fileData.Keys | Sort-Object) { + $lines = $fileData[$file].Lines + $total = $lines.Count + if ($total -lt $MinimumLines) { + continue + } + $covered = ($lines.Values | Where-Object { $_ -gt 0 }).Count + $uncovered = @($lines.GetEnumerator() | Where-Object { $_.Value -eq 0 } | + ForEach-Object { $_.Key } | Sort-Object) + $pct = if ($total -eq 0) { 0 } else { [int][math]::Floor($covered * 100 / $total) } + + [PSCustomObject]@{ + File = $file + Percentage = $pct + CoveredLines = $covered + TotalLines = $total + UncoveredLines = $uncovered + } + } + + $results + } +} + +function Show-FullCodeCoverageReport { + <# + .SYNOPSIS + Displays a colorized file-by-file coverage summary with optional line-level detail. + + .DESCRIPTION + Shows a table of all source files with their coverage percentage, sorted by coverage + (lowest first). When ShowUncoveredLines is specified, also displays the uncovered + line numbers and source text for files below the threshold. + + .PARAMETER LcovPath + Path to the LCOV coverage report file. + + .PARAMETER ShowUncoveredLines + When specified, displays uncovered line numbers and source for files below the + coverage threshold specified by UncoveredThreshold. + + .PARAMETER UncoveredThreshold + Files with coverage percentage at or below this value will have their uncovered lines + displayed when ShowUncoveredLines is set. Defaults to 80. + + .PARAMETER Top + Maximum number of files to display in the summary. Defaults to showing all files. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$LcovPath, + + [Parameter()] + [switch]$ShowUncoveredLines, + + [Parameter()] + [int]$UncoveredThreshold = 80, + + [Parameter()] + [int]$Top = 0 + ) + + process { + $details = Get-FullCodeCoverageDetail -LcovPath $LcovPath + + if (-not $details -or $details.Count -eq 0) { + Write-Host "No coverage data available." + return + } + + # Sort by percentage ascending (worst coverage first) + $sorted = $details | Sort-Object Percentage + + if ($Top -gt 0) { + $sorted = $sorted | Select-Object -First $Top + } + + # Display summary table + Write-Host "" + Write-Host "$($PSStyle.Bold)File Coverage Summary (sorted by coverage, lowest first):$($PSStyle.BoldOff)" + Write-Host "" + + $maxPathLen = ($sorted | ForEach-Object { + # Show relative path from repo root for readability + $_.File.Length + } | Measure-Object -Maximum).Maximum + $maxPathLen = [Math]::Min($maxPathLen, 80) + + foreach ($entry in $sorted) { + $displayPath = $entry.File + if ($displayPath.Length -gt $maxPathLen) { + $displayPath = '...' + $displayPath.Substring($displayPath.Length - $maxPathLen + 3) + } + + $color = if ($entry.Percentage -ge 80) { + $PSStyle.Foreground.Green + } elseif ($entry.Percentage -ge 60) { + $PSStyle.Foreground.Yellow + } else { + $PSStyle.Foreground.Red + } + + $bar = $entry.Percentage.ToString().PadLeft(3) + Write-Host "${color} ${bar}%$($PSStyle.Reset) $($entry.CoveredLines.ToString().PadLeft(5))/$($entry.TotalLines.ToString().PadLeft(5)) $displayPath" + } + + Write-Host "" + $totalFiles = $details.Count + $filesBelow = ($details | Where-Object { $_.Percentage -lt 70 }).Count + Write-Host " $totalFiles files total | $filesBelow files below 70% coverage" + + # Show uncovered lines for low-coverage files + if ($ShowUncoveredLines) { + $lowCovFiles = $sorted | Where-Object { $_.Percentage -le $UncoveredThreshold } + if ($lowCovFiles.Count -eq 0) { + Write-Host "" + Write-Host "All displayed files are above $UncoveredThreshold% coverage." + return + } + + Write-Host "" + Write-Host "$($PSStyle.Bold)Uncovered lines (files at or below ${UncoveredThreshold}% coverage):$($PSStyle.BoldOff)" + + foreach ($entry in $lowCovFiles) { + $filePath = $entry.File + $fileContent = Get-Content -Path $filePath -ErrorAction SilentlyContinue + + Write-Host "" + Write-Host "$($PSStyle.Bold)$filePath$($PSStyle.BoldOff) ($($entry.Percentage)%)" -ForegroundColor Cyan + + if (-not $fileContent -or $entry.UncoveredLines.Count -eq 0) { + continue + } + + $lineNumWidth = ($entry.UncoveredLines[-1]).ToString().Length + foreach ($lineNum in $entry.UncoveredLines) { + $lineIndex = $lineNum - 1 + $lineText = if ($lineIndex -lt $fileContent.Count) { $fileContent[$lineIndex] } else { '' } + $prefix = $lineNum.ToString().PadLeft($lineNumWidth) + Write-Host "$($PSStyle.Foreground.Red) $prefix | $lineText$($PSStyle.Reset)" + } + } + Write-Host "" + } + } +} + #endregion Code coverage functions #region Test project functions