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) }
}