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 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.
 
    .OUTPUTS
        PSCustomObject: Summary, Objects, 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
    )

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

    $map = if ($WorkspaceRoot) { Get-BcAlObjectMap -WorkspaceRoot $WorkspaceRoot } else { @{} }
    $rootPath = if ($WorkspaceRoot) { (Resolve-Path -LiteralPath $WorkspaceRoot).Path } else { '' }

    # Honest denominator: BC's export lists only Covered/Partial lines, so the raw CSV always reads ~100%.
    # In Source mode (default when the object's AL source is in the workspace) the *total* executable lines come
    # from the source instead; covered lines come from the CSV; the union guarantees covered <= executable.
    $useSource = $DenominatorMode -ne 'CoveredOnly' -and [bool]$WorkspaceRoot
    $execCache = @{}

    $objects = [System.Collections.Generic.List[object]]::new()
    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
        $coveredNos = @($o.Lines.Keys | Where-Object { $o.Lines[$_] -gt 0 } | Sort-Object)

        # Determine this object's executable-line universe (the denominator).
        $sourced = $false
        $execNos = $coveredNos
        if ($useSource -and $info -and $info.File -and (Test-Path -LiteralPath $info.File)) {
            # Preprocessor symbols come from the owning app's app.json, which the compile step writes
            # before the tests run - so the denominator matches what alc.exe actually compiled, and lines
            # in a branch this build did not compile are not counted as missing coverage. Cached per app
            # folder by Resolve-BcAlAppContext; the exec cache is keyed by file AND symbols so a workspace
            # holding apps with different symbol sets cannot reuse the wrong result.
            $appSymbols = @((Resolve-BcAlAppContext -Path $info.File).PreprocessorSymbols)
            $execKey = "$($info.File)|$($appSymbols -join ',')"
            if (-not $execCache.ContainsKey($execKey)) {
                $execCache[$execKey] = @(Get-BcAlExecutableLines -Path $info.File -Symbols $appSymbols)
            }
            $srcExec = $execCache[$execKey]
            if ($srcExec.Count -gt 0) {
                $execNos = @(($srcExec + $coveredNos) | Sort-Object -Unique)   # union: covered is always counted
                $sourced = $true
            }
        }
        # CoveredOnly fallback (or no source): denominator is the CSV lines themselves.
        if (-not $sourced) { $execNos = @($o.Lines.Keys | Sort-Object) }

        $coveredSet = @{}; foreach ($n in $coveredNos) { $coveredSet[$n] = $true }
        $total = @($execNos).Count
        $covered = @($execNos | Where-Object { $coveredSet.ContainsKey($_) }).Count
        $relPath = if ($info -and $rootPath -and $info.File.StartsWith($rootPath)) { $info.File.Substring($rootPath.Length).TrimStart('\', '/').Replace('\', '/') } elseif ($info) { $info.File } else { '' }
        $objects.Add([PSCustomObject]@{
                objectType           = $o.Type
                objectId             = $o.Id
                objectName           = if ($info) { $info.Name } else { '' }
                filePath             = $relPath
                denominator          = if ($sourced) { 'Source' } else { 'CoveredOnly' }
                totalExecutableLines = $total
                coveredLines         = $covered
                uncoveredLines       = @($execNos | Where-Object { -not $coveredSet.ContainsKey($_) })
                lineCoverage         = Get-Pct $covered $total
                lines                = @($execNos | ForEach-Object {
                        $h = if ($o.Lines.ContainsKey($_)) { $o.Lines[$_] } else { 0 }
                        [PSCustomObject]@{ lineNo = $_; hits = $h; status = if ($coveredSet.ContainsKey($_)) { 'Covered' } else { 'NotCovered' } }
                    })
            })
    }

    # Summed by hand rather than with Measure-Object: over an EMPTY set Measure-Object emits nothing, so
    # reading .Sum throws under StrictMode and an empty coverage folder crashed the whole conversion
    # instead of reporting 0%. A plain loop has no such edge case.
    $totalLines = 0
    $coveredLines = 0
    foreach ($o in $objects) {
        $totalLines += [int] $o.totalExecutableLines
        $coveredLines += [int] $o.coveredLines
    }
    if ($null -eq $totalLines) { $totalLines = 0 }; if ($null -eq $coveredLines) { $coveredLines = 0 }
    $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
    }

    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
            objects       = @($objects)
        }
        $path = Join-Path $OutputFolder 'coverage-summary.json'
        $doc | ConvertTo-Json -Depth 8 | 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 -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 -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 ($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); TestQuality = $quality; Outputs = @($outputs) }
}