Modules/businessdev.ALbuild.Apps/Public/Get-BcCodeCoverageDelta.ps1
|
function Get-BcCodeCoverageDelta { <# .SYNOPSIS Computes "patch coverage" -- how well the lines you changed (vs a git baseline) are covered by tests. .DESCRIPTION Overall coverage rewards a big tested codebase even when new code is untested; patch coverage answers the question that matters on a pull request: are the lines I just added/changed actually exercised? This diffs the workspace against a git baseline ref, keeps the added/modified .al lines that BC would treat as executable (Get-BcAlExecutableLines), and checks each against the coverage data. Files/objects with no executable changes are ignored. .PARAMETER BaselineRef Git ref to diff against (e.g. 'origin/main', a merge-base SHA, 'HEAD~1'). .PARAMETER HeadRef Optional second ref; default is the working tree (uncommitted changes included). .PARAMETER WorkspaceRoot AL source root (inside a git repo). Coverage is mapped to objects here and the diff is scoped to it. .PARAMETER CoveragePath Raw *.dat folder/file from a coverage-enabled run (converted with the honest denominator). .PARAMETER SummaryPath Alternatively, an existing coverage-summary.json (must have been produced with -WorkspaceRoot so it carries per-line data). .OUTPUTS PSCustomObject: Summary { changedExecutableLines, coveredChangedLines, deltaCoverage, fileCount }, Files[]. #> [CmdletBinding(DefaultParameterSetName = 'Raw')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $BaselineRef, [string] $HeadRef, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot, [Parameter(Mandatory, ParameterSetName = 'Raw')] [string] $CoveragePath, [Parameter(Mandatory, ParameterSetName = 'Summary')] [string] $SummaryPath ) $root = (Resolve-Path -LiteralPath $WorkspaceRoot).Path $repoRoot = (& git -C $root rev-parse --show-toplevel 2>$null) if (-not $repoRoot) { throw "'$WorkspaceRoot' is not inside a git repository." } $repoRoot = $repoRoot.Trim() # 1. Diff for changed .al lines (new-side line numbers), unified=0 so each hunk maps cleanly. $diffArgs = @('-C', $root, 'diff', '--unified=0', '--no-color', '--diff-filter=AM', $BaselineRef) if ($HeadRef) { $diffArgs += $HeadRef } $diffArgs += @('--', '*.al') $diff = & git @diffArgs 2>$null if ($LASTEXITCODE -ne 0) { throw "git diff against '$BaselineRef' failed (is the ref valid?)." } $changedByFile = @{} # full path -> [int] new-side line numbers $curFile = $null; $newLine = 0 foreach ($l in @($diff)) { if ($l -like 'diff --git *') { $curFile = $null; continue } if ($l -like '+++ b/*') { $curFile = Join-Path $repoRoot ($l.Substring(6) -replace '/', [IO.Path]::DirectorySeparatorChar); if (-not $changedByFile.ContainsKey($curFile)) { $changedByFile[$curFile] = [System.Collections.Generic.List[int]]::new() }; continue } if ($l -match '^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@') { $newLine = [int]$Matches[1]; continue } if ($null -eq $curFile) { continue } if ($l.StartsWith('+') -and -not $l.StartsWith('+++')) { $changedByFile[$curFile].Add($newLine); $newLine++ } elseif (-not $l.StartsWith('-')) { $newLine++ } # context (shouldn't appear with unified=0) advances new side } # 2. Coverage objects (per-line covered status), keyed by source file via the object map. $data = if ($PSCmdlet.ParameterSetName -eq 'Summary') { Resolve-BcCoverageData -SummaryPath $SummaryPath } else { Resolve-BcCoverageData -CoveragePath $CoveragePath -WorkspaceRoot $root } $map = Get-BcAlObjectMap -WorkspaceRoot $root $fileToCovered = @{} # full path -> hashtable of covered line numbers foreach ($obj in $data.Objects) { $info = $map["$($obj.objectType):$($obj.objectId)"] if (-not $info) { continue } $set = @{} foreach ($ln in @($obj.lines)) { if ($ln.status -eq 'Covered') { $set[[int]$ln.lineNo] = $true } } $fileToCovered[$info.File] = $set } # 3. Intersect changed lines with executable lines; classify covered vs uncovered. $files = [System.Collections.Generic.List[object]]::new() $totChanged = 0; $totCovered = 0 foreach ($file in ($changedByFile.Keys | Sort-Object)) { if (-not ($file.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase))) { continue } if (-not (Test-Path -LiteralPath $file)) { continue } $exec = @{}; foreach ($e in (Get-BcAlExecutableLines -Path $file)) { $exec[$e] = $true } $changedExec = @($changedByFile[$file] | Where-Object { $exec.ContainsKey($_) } | Sort-Object -Unique) if ($changedExec.Count -eq 0) { continue } $coveredSet = if ($fileToCovered.ContainsKey($file)) { $fileToCovered[$file] } else { @{} } $coveredChanged = @($changedExec | Where-Object { $coveredSet.ContainsKey($_) }) $uncovered = @($changedExec | Where-Object { -not $coveredSet.ContainsKey($_) }) $totChanged += $changedExec.Count; $totCovered += $coveredChanged.Count $rel = $file.Substring($root.Length).TrimStart('\', '/').Replace('\', '/') $files.Add([PSCustomObject]@{ filePath = $rel changedExecutableLines = $changedExec.Count coveredChangedLines = $coveredChanged.Count deltaCoverage = if ($changedExec.Count -gt 0) { [math]::Round(($coveredChanged.Count / $changedExec.Count) * 100, 2) } else { 0.0 } uncoveredLines = @($uncovered) }) } $delta = if ($totChanged -gt 0) { [math]::Round(($totCovered / $totChanged) * 100, 2) } else { 100.0 } Write-ALbuildLog -Level Success ("Patch coverage: {0}% ({1}/{2} changed executable line(s) across {3} file(s))." -f $delta, $totCovered, $totChanged, $files.Count) return [PSCustomObject]@{ Summary = [PSCustomObject]@{ deltaCoverage = $delta coveredChangedLines = $totCovered changedExecutableLines = $totChanged fileCount = $files.Count baselineRef = $BaselineRef } Files = @($files) } } |