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