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? The diff runs from the MERGE BASE of the baseline and the head, so only the pull request's own changes count - not what landed on the target branch since it was branched. A changed line is judged by the statement it belongs to: changing an argument on line 3 of a call means the call has changed. Lines BC cannot report ([NonDebuggable] bodies) and lines albuild.json excludes are listed separately and are not in the percentage, exactly as in Convert-BcCodeCoverage. .PARAMETER BaselineRef Git ref to compare against (e.g. 'origin/dev', a SHA). Omit it and pass -TargetBranch instead. .PARAMETER TargetBranch The pull request's target branch ('dev' or 'refs/heads/dev'); compared as 'origin/<branch>'. .PARAMETER Fetch Fetch the target branch from origin first (build agents often hold only the PR head). .PARAMETER NoMergeBase Diff against the baseline ref itself instead of the merge base. .PARAMETER HeadRef Optional head 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 produced with -WorkspaceRoot. .PARAMETER MinDeltaCoverage Minimum patch coverage in percent. Sets Summary.passed; with -ThrowOnFailure a lower value throws. A change without any executable line passes (there is nothing to judge). .PARAMETER OutputFolder Write coverage-delta.json and coverage-delta.md (uncovered changed lines with their source) here. .OUTPUTS PSCustomObject: Summary { deltaCoverage, changedExecutableLines, coveredChangedLines, fileCount, baselineRef, mergeBase, measurable, passed, minDeltaCoverage, unmeasuredChangedLines, excludedChangedLines }, Files[], Outputs[]. #> [CmdletBinding(DefaultParameterSetName = 'Raw')] [OutputType([PSCustomObject])] param( [string] $BaselineRef, [string] $TargetBranch, [switch] $Fetch, [switch] $NoMergeBase, [string] $HeadRef, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot, [Parameter(Mandatory, ParameterSetName = 'Raw')] [string] $CoveragePath, [Parameter(Mandatory, ParameterSetName = 'Summary')] [string] $SummaryPath, [ValidateRange(0, 100)] [double] $MinDeltaCoverage = -1, [switch] $ThrowOnFailure, [string] $OutputFolder ) $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 = [System.IO.Path]::GetFullPath("$repoRoot".Trim()) # 1. The comparison base: the given ref or origin/<target>, narrowed to the merge base with the head. if (-not $BaselineRef) { if (-not $TargetBranch) { throw 'Pass -BaselineRef or -TargetBranch.' } $branch = $TargetBranch -replace '^refs/heads/', '' if ($Fetch) { & git -C $root fetch --no-tags origin ('+refs/heads/{0}:refs/remotes/origin/{0}' -f $branch) 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { Write-ALbuildLog -Level Warning "Could not fetch '$branch' from origin; using what the clone already has." } } $BaselineRef = "origin/$branch" } $base = $BaselineRef $mergeBase = '' if (-not $NoMergeBase) { $headForBase = if ($HeadRef) { $HeadRef } else { 'HEAD' } $mb = & git -C $root merge-base $BaselineRef $headForBase 2>$null if ($LASTEXITCODE -eq 0 -and "$mb".Trim()) { $mergeBase = "$mb".Trim(); $base = $mergeBase } else { Write-ALbuildLog -Level Warning ("No merge base between '{0}' and '{1}' - the clone is probably shallow (raise fetchDepth or use -Fetch). " -f $BaselineRef, $headForBase + 'Comparing against the ref itself, which also counts changes made on the target branch since branching.') } } Write-ALbuildLog ("Patch coverage: comparing against '{0}'{1}." -f $BaselineRef, $(if ($mergeBase) { " (merge base $($mergeBase.Substring(0, [Math]::Min(10, $mergeBase.Length))))" } else { '' })) # 2. 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', $base) if ($HeadRef) { $diffArgs += $HeadRef } $diffArgs += @('--', '*.al') $diff = & git @diffArgs 2>$null if ($LASTEXITCODE -ne 0) { throw "git diff against '$base' failed (is the ref valid?)." } $changedByFile = @{} $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++ } } # 3. Coverage per object, with the same denominator, NonDebuggable and exclusion rules as the report. $data = if ($PSCmdlet.ParameterSetName -eq 'Summary') { Resolve-BcCoverageData -SummaryPath $SummaryPath } else { Resolve-BcCoverageData -CoveragePath $CoveragePath -WorkspaceRoot $root } $deltaList = New-Object System.Collections.Generic.List[string] try { $deltaConfig = Get-ALbuildProjectConfig -AppFolder $root -WorkspaceRoot $root if ($deltaConfig.Has('ExcludeProjects')) { foreach ($name in $deltaConfig.ExcludeProjects) { if ("$name".Trim()) { $deltaList.Add("$name".Trim()) } } } } catch { Write-ALbuildLog -Level Verbose "No albuild.json exclusions at '$root': $($_.Exception.Message)" } $deltaExcludes = $deltaList.ToArray() $map = Get-BcAlObjectMap -WorkspaceRoot $root -ExcludeProjects $deltaExcludes $prop = { param($o, $n) if ($o -and ($o.PSObject.Properties.Name -contains $n)) { $o.$n } else { $null } } $objectByFile = @{} $mapFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($info in $map.Values) { [void]$mapFiles.Add($info.File) } foreach ($obj in $data.Objects) { $info = $map["$($obj.objectType):$($obj.objectId)"] if ($info) { $objectByFile[$info.File] = $obj } } # 4. Changed statements: a changed line counts for the statement it belongs to. $files = [System.Collections.Generic.List[object]]::new() $totChanged = 0; $totCovered = 0; $totUnmeasured = 0; $totExcluded = 0 foreach ($file in ($changedByFile.Keys | Sort-Object)) { if (-not ($file.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase))) { continue } if (-not (Test-Path -LiteralPath $file)) { continue } $context = Resolve-BcAlAppContext -Path $file if ($deltaList.Count -gt 0 -and $context.AppFolder -and ($deltaExcludes -contains (Split-Path -Path $context.AppFolder -Leaf))) { continue } $model = Get-BcAlSourceModel -Path $file -Symbols @($context.PreprocessorSymbols) $obj = $objectByFile[$file] $measured = @{}; $covered = @{} if ($obj) { foreach ($ln in @($obj.lines)) { $measured[[int]$ln.lineNo] = $true; if ($ln.status -eq 'Covered') { $covered[[int]$ln.lineNo] = $true } } } elseif (-not $mapFiles.Contains($file)) { continue } # test code, an excluded project, no AL object else { foreach ($n in $model.ExecutableLines) { $measured[[int]$n] = $true } } $unmeasuredSet = @{}; foreach ($n in @(& $prop $obj 'unmeasuredLines')) { if ($null -ne $n) { $unmeasuredSet[[int]$n] = $true } } if (-not $obj) { foreach ($n in $model.UnmeasuredLines) { $unmeasuredSet[[int]$n] = $true } } $excludedSet = @{}; foreach ($n in @(& $prop $obj 'excludedLines')) { if ($null -ne $n) { $excludedSet[[int]$n] = $true } } $statements = @($changedByFile[$file] | ForEach-Object { if ($model.StatementOf.ContainsKey($_)) { [int]$model.StatementOf[$_] } else { [int]$_ } } | Sort-Object -Unique) $changedExec = @($statements | Where-Object { $measured.ContainsKey($_) }) $unmeasuredChanged = @($statements | Where-Object { $unmeasuredSet.ContainsKey($_) }) $excludedChanged = @($statements | Where-Object { $excludedSet.ContainsKey($_) }) if ($changedExec.Count -eq 0 -and $unmeasuredChanged.Count -eq 0 -and $excludedChanged.Count -eq 0) { continue } $coveredChanged = @($changedExec | Where-Object { $covered.ContainsKey($_) }) $uncovered = @($changedExec | Where-Object { -not $covered.ContainsKey($_) }) $totChanged += $changedExec.Count; $totCovered += $coveredChanged.Count $totUnmeasured += $unmeasuredChanged.Count; $totExcluded += $excludedChanged.Count $procedureOf = { param($n) $p = @($model.Procedures | Where-Object { $n -ge $_.Line -and $n -le $_.EndLine }) | Select-Object -First 1; if ($p) { $p.Name } else { '' } } $files.Add([PSCustomObject]@{ filePath = $file.Substring($root.Length).TrimStart('\', '/').Replace('\', '/') objectName = if ($obj) { "$($obj.objectType) $($obj.objectId) $($obj.objectName)".Trim() } else { '' } changedExecutableLines = $changedExec.Count coveredChangedLines = $coveredChanged.Count deltaCoverage = if ($changedExec.Count -gt 0) { [math]::Round(($coveredChanged.Count / $changedExec.Count) * 100, 2) } else { 100.0 } uncoveredLines = [int[]]@($uncovered) uncovered = @($uncovered | ForEach-Object { [PSCustomObject]@{ line = $_; procedure = (& $procedureOf $_) } }) unmeasuredLines = [int[]]@($unmeasuredChanged) excludedLines = [int[]]@($excludedChanged) }) } $measurable = $totChanged -gt 0 $delta = if ($measurable) { [math]::Round(($totCovered / $totChanged) * 100, 2) } else { 100.0 } $gate = $MinDeltaCoverage -ge 0 $passed = (-not $gate) -or (-not $measurable) -or ($delta -ge $MinDeltaCoverage) $summary = [PSCustomObject]@{ deltaCoverage = $delta coveredChangedLines = $totCovered changedExecutableLines = $totChanged fileCount = $files.Count baselineRef = $BaselineRef mergeBase = $mergeBase measurable = $measurable passed = $passed minDeltaCoverage = if ($gate) { $MinDeltaCoverage } else { $null } unmeasuredChangedLines = $totUnmeasured excludedChangedLines = $totExcluded } Write-ALbuildLog -Level Success ("Patch coverage: {0}% ({1}/{2} changed executable line(s) across {3} file(s))." -f $delta, $totCovered, $totChanged, $files.Count) if ($totUnmeasured -gt 0) { Write-ALbuildLog -Level Information "Changed lines in [NonDebuggable] code, not measurable and not in the percentage: $totUnmeasured." } if ($totExcluded -gt 0) { Write-ALbuildLog -Level Information "Changed lines excluded by albuild.json, not in the percentage: $totExcluded." } $outputs = [System.Collections.Generic.List[string]]::new() if ($OutputFolder) { New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null $jsonPath = Join-Path $OutputFolder 'coverage-delta.json' [PSCustomObject]@{ schemaVersion = '1.0'; tool = 'ALbuild'; operation = 'coverage.delta'; summary = $summary; files = @($files) } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 $outputs.Add($jsonPath) $mdPath = Join-Path $OutputFolder 'coverage-delta.md' Write-CoverageDeltaMarkdown -Summary $summary -Files @($files) -SourceRoot $root -Path $mdPath $outputs.Add($mdPath) } if ($gate) { if (-not $measurable) { Write-ALbuildLog -Level Information "Patch coverage gate: no changed executable line - nothing to judge, passed." } elseif ($passed) { Write-ALbuildLog -Level Success ("Patch coverage gate PASSED: {0}% >= {1}%." -f $delta, $MinDeltaCoverage) } else { $msg = "Patch coverage gate FAILED: {0}% of the changed lines are covered, {1}% required ({2} uncovered changed line(s))." -f $delta, $MinDeltaCoverage, ($totChanged - $totCovered) if ($ThrowOnFailure) { throw $msg } Write-ALbuildLog -Level Warning $msg } } return [PSCustomObject]@{ Summary = $summary; Files = @($files); Outputs = @($outputs) } } |