Modules/businessdev.ALbuild.Apps/Public/Convert-BcCodeCoverage.ps1

function Convert-BcCodeCoverage {
    <#
    .SYNOPSIS
        Converts raw Business Central code coverage (.dat CSV from Invoke-BcContainerTest -CodeCoverage)
        into ALbuild JSON, Cobertura XML and/or a Markdown summary.
 
    .DESCRIPTION
        The raw rows are VariableText CSV: ObjectType, ObjectID, LineNo, CoverageStatus, NoOfHits. This
        aggregates them per object (max hit count per line - union semantics across chunks), attaches each
        object to its source file via the workspace AL object map, computes line coverage, and emits the
        requested formats. With -WorkspaceRoot the result is restricted to objects whose source is in the
        workspace (your app code); without it, every tracked object is included.
 
    .PARAMETER CoveragePath
        A folder of raw *.dat files (or a single .dat file) produced by the coverage-enabled test run.
 
    .PARAMETER WorkspaceRoot
        AL source root. Restricts/maps coverage to your app objects and sets file paths.
 
    .PARAMETER Format
        One or more of ALbuildJson, Cobertura, Markdown, Html. 'Html' writes a self-contained report to
        '<OutputFolder>/html/index.html' - the directory the Azure DevOps coverage tab renders. The
        '##vso[codecoverage.publish]' logging command uploads that directory but does NOT generate it,
        which is why the tab stayed empty without this format.
 
    .PARAMETER OutputFolder
        Where to write the output files. Default: the current location.
 
    .PARAMETER ExcludeNeverExecutedObjects
        Leave objects that no test touched out of the calculation, restoring the behaviour of module 2.23
        and earlier. Business Central reports lines only for objects LOADED during the run, so by default
        such an object produced no rows, never entered the result, and could not lower the percentage - the
        figure answered "how much of the code the tests loaded is covered" rather than "how much of the app
        is covered". They are now counted with zero covered lines.
 
        Use this only where the workspace holds product apps that are deliberately not published into the
        test container; there the objects genuinely cannot be exercised and counting them would be unfair.
        Objects without executable lines are never added either way.
 
    .PARAMETER ExcludeProjects
        Project folder leaf names to leave out of the measurement entirely - the same names as the repo-root
        albuild.json 'excludeProjects' and Get-BcProjectBuildOrder. Omit it and the workspace-root
        albuild.json is read, so a repository that already excludes an app from the build excludes it from
        coverage too without any pipeline change. Pass a list to add the pipeline's own parameter on top.
 
    .PARAMETER TestQuality
        The result of Get-BcTestQuality, included in the Markdown and HTML reports. Coverage measures which
        lines RAN; it cannot tell whether a wrong result would have been caught, because a [Test] method
        that asserts nothing still covers every line it touches. Reporting the percentage on its own
        therefore overstates what the suite proves, so the assertion figures belong next to it.
 
        Omit it and, when -WorkspaceRoot is known, it is computed automatically - the report carries the
        caveat by default rather than only when someone remembers to ask. Pass a value to avoid scanning
        the workspace twice when the caller already has it.
 
    .PARAMETER IncludeNonDebuggable
        Count the bodies of [NonDebuggable] methods in the percentage, as uncovered lines. Business Central
        never reports those lines, so by default they stay out of the percentage and are listed separately
        (Unmeasured) with object, procedure and line count. Omitted, albuild.json
        'coverage.includeNonDebuggable' decides.
 
    .PARAMETER Exclusions
        Lines that are provably unreachable in a test run (StartSession under test isolation, a background
        hand-over), each { object, procedure?, lines?, reason }. 'object' is "<Type> <Id>" or "<Type> <Name>";
        'lines' narrows the entry to source line ranges ("10-12, 20"); 'reason' is mandatory - an entry without
        one is ignored with a warning. Excluded lines leave the percentage and are listed separately
        (Excluded), never silently. Omitted, albuild.json 'coverage.exclusions' is read.
 
    .OUTPUTS
        PSCustomObject: Summary, Objects, Unmeasured, Excluded, TestQuality, Outputs (the written file paths).
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $CoveragePath,
        [string] $WorkspaceRoot,
        [ValidateSet('ALbuildJson', 'Cobertura', 'Markdown', 'Html')] [string[]] $Format = @('ALbuildJson'),
        [ValidateSet('Auto', 'Source', 'CoveredOnly')] [string] $DenominatorMode = 'Auto',
        [string] $OutputFolder = (Get-Location).Path,
        [object] $TestQuality,
        [switch] $ExcludeNeverExecutedObjects,
        [string[]] $ExcludeProjects,
        [switch] $IncludeNonDebuggable,
        [object[]] $Exclusions
    )

    function Get-Pct([int] $covered, [int] $total) { if ($total -le 0) { return 0.0 } return [math]::Round(($covered / $total) * 100, 2) }

    $datFiles = @(if (Test-Path -LiteralPath $CoveragePath -PathType Container) {
            Get-ChildItem -LiteralPath $CoveragePath -Filter '*.dat' -File -Recurse
        }
        else { Get-Item -LiteralPath $CoveragePath })
    if ($datFiles.Count -eq 0) { throw "No coverage .dat files found at '$CoveragePath'." }

    # Parse rows: Type, Id, LineNo, Status, Hits.
    $byObject = @{}
    foreach ($file in $datFiles) {
        foreach ($line in (Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue)) {
            $p = $line -split ','
            if ($p.Count -lt 5 -or $p[1] -notmatch '^\d+$' -or $p[2] -notmatch '^\d+$') { continue }
            $type = (Get-Culture).TextInfo.ToTitleCase($p[0].Trim().ToLowerInvariant())
            $id = [int]$p[1]; $lineNo = [int]$p[2]; $hits = [int]$p[4]
            $key = "$type`:$id"
            if (-not $byObject.ContainsKey($key)) { $byObject[$key] = @{ Type = $type; Id = $id; Lines = @{} } }
            $lines = $byObject[$key].Lines
            if (-not $lines.ContainsKey($lineNo) -or $hits -gt $lines[$lineNo]) { $lines[$lineNo] = $hits }
        }
    }

    # Projects the repository excludes from the BUILD must not be measured either: they are never
    # published, so nothing can cover them, and counting them adds a block that is uncovered by
    # construction (businessdev.Api.Banking's 'demo' app: 21 objects, 1226 lines). Read from the same
    # repo-root albuild.json the build tasks use, so no pipeline has to opt in - callers can still pass an
    # explicit list to add the pipeline's own 'excludeProjects' parameter on top.
    # Built as a List and iterated with a plain foreach on purpose. An unbound [string[]] parameter is a
    # TYPED null, and '@($typedNull).Count' throws on Windows PowerShell 5.1 - the edition the DevOps tasks
    # actually run - while being perfectly happy on PowerShell 7. This exact shape has bitten this module
    # before, so the null never gets near a '.Count' here.
    $projectConfig = $null
    if ($WorkspaceRoot) {
        try { $projectConfig = Get-ALbuildProjectConfig -AppFolder $WorkspaceRoot -WorkspaceRoot $WorkspaceRoot }
        catch { Write-ALbuildLog -Level Verbose "No albuild.json at '$WorkspaceRoot': $($_.Exception.Message)" }
    }
    $excludeList = New-Object System.Collections.Generic.List[string]
    foreach ($name in $ExcludeProjects) { if ("$name".Trim()) { $excludeList.Add("$name".Trim()) } }
    if (-not $PSBoundParameters.ContainsKey('ExcludeProjects') -and $projectConfig -and $projectConfig.Has('ExcludeProjects')) {
        foreach ($name in $projectConfig.ExcludeProjects) { if ("$name".Trim()) { $excludeList.Add("$name".Trim()) } }
    }

    # albuild.json 'coverage' block: the defaults for -IncludeNonDebuggable and -Exclusions.
    $coverageConfig = if ($projectConfig -and $projectConfig.Has('Coverage')) { $projectConfig.Coverage } else { $null }
    $configValue = { param($name) if ($coverageConfig -and ($coverageConfig.PSObject.Properties.Name -contains $name)) { $coverageConfig.$name } else { $null } }
    $countNonDebuggable = if ($PSBoundParameters.ContainsKey('IncludeNonDebuggable')) { [bool]$IncludeNonDebuggable } else { [bool](& $configValue 'includeNonDebuggable') }
    $exclusionEntries = if ($PSBoundParameters.ContainsKey('Exclusions')) { $Exclusions } else { & $configValue 'exclusions' }
    $exclusionRules = @(ConvertTo-BcCoverageExclusionRule -Entry $exclusionEntries)
    # The exclusions are logged by Get-BcAlObjectMap, which is the only place that knows which of them
    # matched a project at all - a name that matches nothing is a no-op, not an applied exclusion.
    $map = if ($WorkspaceRoot) { Get-BcAlObjectMap -WorkspaceRoot $WorkspaceRoot -ExcludeProjects $excludeList.ToArray() } else { @{} }
    $rootPath = if ($WorkspaceRoot) { (Resolve-Path -LiteralPath $WorkspaceRoot).Path } else { '' }
    if ($WorkspaceRoot -and $map.Count -eq 0) {
        # Not an error - but it is the one state in which every figure below is zero, so it must be said
        # out loud rather than left to be inferred from a 0% report.
        Write-ALbuildLog -Level Warning ("No measurable product object found under '{0}': every app there is a declared test app, is excluded, or has no AL source. " -f $rootPath +
            'Coverage is reported as 0 object(s).')
    }

    # Honest denominator: BC's export lists only Covered/Partial lines, so the raw CSV always reads ~100%.
    # In Source mode the executable lines come from the source (Get-BcAlSourceModel), the covered ones from
    # the CSV; the union guarantees covered <= executable.
    $useSource = $DenominatorMode -ne 'CoveredOnly' -and [bool]$WorkspaceRoot
    $modelCache = @{}
    $getModel = {
        param($info)
        if (-not $info -or -not $info.File -or -not (Test-Path -LiteralPath $info.File)) { return $null }
        # Symbols from the owning app's app.json, which the compile step writes before the tests run, so a
        # branch this build did not compile is not counted. Cached per file AND symbol set.
        $appSymbols = @((Resolve-BcAlAppContext -Path $info.File).PreprocessorSymbols)
        $modelKey = "$($info.File)|$($appSymbols -join ',')"
        if (-not $modelCache.ContainsKey($modelKey)) { $modelCache[$modelKey] = Get-BcAlSourceModel -Path $info.File -Symbols $appSymbols }
        return $modelCache[$modelKey]
    }

    $objects = [System.Collections.Generic.List[object]]::new()
    $unmeasured = [System.Collections.Generic.List[object]]::new()
    $excluded = [System.Collections.Generic.List[object]]::new()

    # One coverage record per object; $hits maps line -> hit count from the CSV (empty for never-executed).
    $newRecord = {
        param([string] $type, [int] $id, $info, [hashtable] $hits, [bool] $neverExecuted)
        $name = if ($info) { $info.Name } else { '' }
        $relPath = if ($info -and $rootPath -and $info.File.StartsWith($rootPath)) { $info.File.Substring($rootPath.Length).TrimStart('\', '/').Replace('\', '/') } elseif ($info) { $info.File } else { '' }
        $isCovered = { param($n) $hits.ContainsKey($n) -and $hits[$n] -gt 0 }
        $model = if ($useSource) { & $getModel $info } else { $null }
        $sourced = $null -ne $model -and (@($model.ExecutableLines).Count + @($model.UnmeasuredLines).Count) -gt 0
        if ($neverExecuted -and -not $sourced) { return $null }

        $measured = [System.Collections.Generic.SortedSet[int]]::new()
        $unmeasuredNos = [System.Collections.Generic.SortedSet[int]]::new()
        $excludedNos = [System.Collections.Generic.SortedSet[int]]::new()
        $procedures = @()
        $continuations = @()
        if ($sourced) {
            foreach ($n in $model.ExecutableLines) { [void]$measured.Add([int]$n) }
            foreach ($n in $hits.Keys) { if (& $isCovered $n) { [void]$measured.Add([int]$n) } }   # covered is always counted
            foreach ($n in $model.UnmeasuredLines) { if ($countNonDebuggable) { [void]$measured.Add([int]$n) } else { [void]$unmeasuredNos.Add([int]$n) } }
            $procedures = @($model.Procedures)

            foreach ($rule in $exclusionRules) {
                if (-not (Test-BcCoverageExclusionRuleObject -Rule $rule -Type $type -Id $id -Name $name)) { continue }
                $candidates = if ($rule.Procedure) {
                    @($procedures | Where-Object { $_.Name -ieq $rule.Procedure } | ForEach-Object { $_.Lines })
                }
                else { @($measured) + @($unmeasuredNos) }
                $ruleLines = @($candidates | Where-Object { $null -eq $rule.Lines -or $rule.Lines.Contains([int]$_) } | Sort-Object -Unique)
                if ($ruleLines.Count -eq 0) { continue }
                $rule.Matched = $true
                foreach ($n in $ruleLines) { [void]$excludedNos.Add([int]$n); [void]$measured.Remove([int]$n); [void]$unmeasuredNos.Remove([int]$n) }
                $excluded.Add([PSCustomObject]@{
                        objectType   = $type
                        objectId     = $id
                        objectName   = $name
                        filePath     = $relPath
                        procedure    = $rule.Procedure
                        lines        = [int[]]$ruleLines
                        lineCount    = $ruleLines.Count
                        coveredLines = @($ruleLines | Where-Object { & $isCovered $_ }).Count
                        reason       = $rule.Reason
                    })
            }

            foreach ($p in $procedures) {
                if (-not $p.NonDebuggable) { continue }
                $counted = @($p.Lines | Where-Object { $(if ($countNonDebuggable) { $measured.Contains([int]$_) } else { $unmeasuredNos.Contains([int]$_) }) })
                if ($counted.Count -eq 0) { continue }
                $unmeasured.Add([PSCustomObject]@{
                        objectType = $type
                        objectId   = $id
                        objectName = $name
                        filePath   = $relPath
                        procedure  = $p.Name
                        line       = $p.Line
                        endLine    = $p.EndLine
                        lines      = $counted.Count
                        reason     = 'NonDebuggable'
                        included   = $countNonDebuggable
                    })
            }
            # A continuation line shares the status of the line its statement starts on.
            $continuations = @($model.StatementOf.Keys | Sort-Object | Where-Object { $measured.Contains([int]$model.StatementOf[$_]) } |
                    ForEach-Object { [PSCustomObject]@{ lineNo = [int]$_; statementLine = [int]$model.StatementOf[$_] } })
        }
        else {
            foreach ($n in $hits.Keys) { [void]$measured.Add([int]$n) }   # CoveredOnly: the CSV lines themselves
        }

        $covered = @($measured | Where-Object { & $isCovered $_ }).Count
        $procedureRecords = @(foreach ($p in $procedures) {
                $pl = @($p.Lines | Where-Object { $measured.Contains([int]$_) })
                $pc = @($pl | Where-Object { & $isCovered $_ }).Count
                [PSCustomObject]@{
                    name            = $p.Name
                    kind            = $p.Kind
                    line            = $p.Line
                    endLine         = $p.EndLine
                    nonDebuggable   = [bool]$p.NonDebuggable
                    measured        = $pl.Count -gt 0
                    executableLines = $pl.Count
                    coveredLines    = $pc
                    lineCoverage    = Get-Pct $pc $pl.Count
                    uncoveredLines  = [int[]]@($pl | Where-Object { -not (& $isCovered $_) })
                }
            })
        return [PSCustomObject]@{
            objectType           = $type
            objectId             = $id
            objectName           = $name
            filePath             = $relPath
            denominator          = if ($sourced) { 'Source' } else { 'CoveredOnly' }
            totalExecutableLines = $measured.Count
            coveredLines         = $covered
            uncoveredLines       = [int[]]@($measured | Where-Object { -not (& $isCovered $_) })
            lineCoverage         = Get-Pct $covered $measured.Count
            neverExecuted        = $neverExecuted
            unmeasuredLines      = [int[]]@($unmeasuredNos)
            excludedLines        = [int[]]@($excludedNos)
            procedures           = $procedureRecords
            continuations        = $continuations
            lines                = @($measured | ForEach-Object {
                    $h = if ($hits.ContainsKey($_)) { $hits[$_] } else { 0 }
                    [PSCustomObject]@{ lineNo = $_; hits = $h; status = if ($h -gt 0) { 'Covered' } else { 'NotCovered' } }
                })
        }
    }

    foreach ($key in ($byObject.Keys | Sort-Object)) {
        $o = $byObject[$key]
        $info = $map[$key]
        if ($WorkspaceRoot -and -not $info) { continue }   # only our app's objects when a workspace is given
        $record = & $newRecord $o.Type $o.Id $info $o.Lines $false
        if ($record) { $objects.Add($record) }
    }

    # Objects NO test ever touched. BC reports nothing for an object that was never loaded, so it would
    # contribute neither covered nor executable lines and could not lower the percentage. Measured on
    # businessdev.EInvoice: 43 of 75 product objects were invisible this way. They count with zero covered
    # lines. Objects without executable lines (enums, interfaces, plain tables) are skipped.
    $neverExecuted = 0
    if ($useSource -and $map.Count -gt 0 -and -not $ExcludeNeverExecutedObjects) {
        foreach ($key in ($map.Keys | Sort-Object)) {
            if ($byObject.ContainsKey($key)) { continue }
            $parts = $key -split ':'
            $record = & $newRecord $parts[0] ([int]$parts[1]) $map[$key] @{} $true
            if (-not $record) { continue }
            if ([int]$record.totalExecutableLines -eq 0 -and @($record.unmeasuredLines).Count -eq 0 -and @($record.excludedLines).Count -eq 0) { continue }
            $neverExecuted++
            $objects.Add($record)
        }
    }

    foreach ($rule in $exclusionRules) {
        if (-not $rule.Matched) {
            Write-ALbuildLog -Level Warning ("Coverage exclusion '{0}' matches no executable line; nothing was excluded for it. Correct or remove the entry." -f $rule.Label)
        }
    }

    # Summed by hand rather than with Measure-Object: over an EMPTY set Measure-Object emits nothing, and
    # reading .Sum then throws under StrictMode.
    $totalLines = 0
    $coveredLines = 0
    foreach ($o in $objects) {
        $totalLines += [int] $o.totalExecutableLines
        $coveredLines += [int] $o.coveredLines
    }
    $unmeasuredLineCount = 0
    foreach ($u in $unmeasured) { $unmeasuredLineCount += [int]$u.lines }
    $excludedLineCount = 0
    foreach ($x in $excluded) { $excludedLineCount += [int]$x.lineCount }
    $sourcedCount = @($objects | Where-Object { $_.denominator -eq 'Source' }).Count
    $summary = [PSCustomObject]@{
        lineCoverage          = Get-Pct $coveredLines $totalLines
        coveredLines          = $coveredLines
        totalExecutableLines  = $totalLines
        objectCount           = $objects.Count
        denominatorMode       = if ($sourcedCount -eq $objects.Count -and $objects.Count -gt 0) { 'Source' } elseif ($sourcedCount -gt 0) { 'Mixed' } else { 'CoveredOnly' }
        sourcedObjectCount    = $sourcedCount
        # "0% covered" and "never even reached" are different findings.
        objectsNeverExecuted  = $neverExecuted
        # [NonDebuggable] bodies, which BC never reports. In the percentage only when nonDebuggableIncluded.
        unmeasuredProcedures  = $unmeasured.Count
        unmeasuredLines       = $unmeasuredLineCount
        nonDebuggableIncluded = $countNonDebuggable
        # Lines albuild.json declares unreachable, each entry with its reason. Never in the percentage.
        excludedLines         = $excludedLineCount
        exclusionCount        = $excluded.Count
    }

    New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null
    $outputs = [System.Collections.Generic.List[string]]::new()

    if ($Format -contains 'ALbuildJson') {
        $doc = [PSCustomObject]@{
            schemaVersion = '1.0'; tool = 'ALbuild'; operation = 'coverage.collect'
            createdAt     = (Get-Date).ToUniversalTime().ToString('o')
            summary       = $summary
            unmeasured    = @($unmeasured)
            excluded      = @($excluded)
            objects       = @($objects)
        }
        $path = Join-Path $OutputFolder 'coverage-summary.json'
        $doc | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path -Encoding UTF8
        $outputs.Add($path)
    }
    if ($Format -contains 'Cobertura') {
        $path = Join-Path $OutputFolder 'cobertura.xml'
        Write-CoberturaXml -Objects $objects -Summary $summary -SourceRoot $rootPath -Path $path
        $outputs.Add($path)
    }
    # Test quality travels WITH the coverage figure, because the figure alone overstates what the suite
    # proves: a [Test] method that asserts nothing covers every line it touches. Scanning is best-effort -
    # like the HTML report, this is reporting, and it must never take down a run that measured correctly.
    $quality = $TestQuality
    if ($null -eq $quality -and $rootPath) {
        try { $quality = Get-BcTestQuality -WorkspaceRoot $rootPath }
        catch {
            $quality = $null
            Write-ALbuildLog -Level Warning ("Could not assess test quality for the coverage report ({0}). " -f $_.Exception.Message +
                'The coverage figures are unaffected.')
        }
    }

    if ($Format -contains 'Markdown') {
        $path = Join-Path $OutputFolder 'coverage.md'
        Write-CoverageMarkdown -Objects $objects -Summary $summary -Unmeasured $unmeasured -Excluded $excluded -SourceRoot $rootPath -Path $path -TestQuality $quality
        $outputs.Add($path)
    }
    if ($Format -contains 'Html') {
        # Its own folder: the Azure DevOps coverage tab is pointed at a report DIRECTORY, not a file.
        $path = Join-Path (Join-Path $OutputFolder 'html') 'index.html'
        Write-CoverageHtml -Objects $objects -Summary $summary -Unmeasured $unmeasured -Excluded $excluded -SourceRoot $rootPath -Path $path -TestQuality $quality | Out-Null
        $outputs.Add($path)
    }

    Write-ALbuildLog -Level Success ("Code coverage: {0}% ({1}/{2} lines across {3} object(s))." -f $summary.lineCoverage, $coveredLines, $totalLines, $objects.Count)
    if ($unmeasured.Count -gt 0) {
        $state = if ($countNonDebuggable) { 'counted as uncovered (coverage.includeNonDebuggable)' } else { 'NOT in the percentage' }
        Write-ALbuildLog -Level Information ("Not measurable: {0} [NonDebuggable] procedure(s) with {1} line(s), {2}." -f $unmeasured.Count, $unmeasuredLineCount, $state)
    }
    if ($excluded.Count -gt 0) {
        Write-ALbuildLog -Level Information ("Excluded by albuild.json: {0} line(s) from {1} entr(y/ies), each with its reason in the report." -f $excludedLineCount, $excluded.Count)
    }
    if ($null -ne $quality) {
        Write-ALbuildLog -Level Information ("Test quality: {0}/{1} test(s) assert (score {2}); {3} without assertions, {4} empty." -f `
                $quality.Summary.testsWithAssertions, $quality.Summary.testCount, $quality.Summary.qualityScore,
            $quality.Summary.testsWithoutAssertions, $quality.Summary.emptyTests)
    }
    return [PSCustomObject]@{ Summary = $summary; Objects = @($objects); Unmeasured = @($unmeasured); Excluded = @($excluded); TestQuality = $quality; Outputs = @($outputs) }
}