Modules/businessdev.ALbuild.Apps/Private/Write-BcCoverageReports.ps1

function Write-CoberturaXml {
    <#
    .SYNOPSIS
        Writes Cobertura XML from ALbuild coverage objects (consumed by Azure DevOps / ReportGenerator).
        Branch coverage is omitted (BC does not provide branch data).
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Objects,
        [Parameter(Mandatory)] [object] $Summary,
        [string] $SourceRoot,
        [Parameter(Mandatory)] [string] $Path
    )
    $ic = [System.Globalization.CultureInfo]::InvariantCulture
    $rate = ([math]::Round($Summary.lineCoverage / 100, 4)).ToString($ic)
    $doc = New-Object System.Xml.XmlDocument
    $doc.AppendChild($doc.CreateXmlDeclaration('1.0', 'UTF-8', $null)) | Out-Null
    $cov = $doc.CreateElement('coverage')
    $cov.SetAttribute('line-rate', $rate)
    $cov.SetAttribute('branch-rate', '0')
    $cov.SetAttribute('lines-covered', [string]$Summary.coveredLines)
    $cov.SetAttribute('lines-valid', [string]$Summary.totalExecutableLines)
    $cov.SetAttribute('branches-covered', '0')
    $cov.SetAttribute('branches-valid', '0')
    $cov.SetAttribute('complexity', '0')
    $cov.SetAttribute('version', 'ALbuild')
    $cov.SetAttribute('timestamp', [string][DateTimeOffset]::UtcNow.ToUnixTimeSeconds())
    $doc.AppendChild($cov) | Out-Null

    $sources = $doc.CreateElement('sources')
    if ($SourceRoot) { $s = $doc.CreateElement('source'); $s.InnerText = $SourceRoot; $sources.AppendChild($s) | Out-Null }
    $cov.AppendChild($sources) | Out-Null

    $packages = $doc.CreateElement('packages'); $cov.AppendChild($packages) | Out-Null
    $package = $doc.CreateElement('package')
    $package.SetAttribute('name', 'ALbuild')
    $package.SetAttribute('line-rate', $rate)
    $package.SetAttribute('branch-rate', '0')
    $package.SetAttribute('complexity', '0')
    $packages.AppendChild($package) | Out-Null
    $classes = $doc.CreateElement('classes'); $package.AppendChild($classes) | Out-Null

    foreach ($o in $Objects) {
        $class = $doc.CreateElement('class')
        $class.SetAttribute('name', ("{0} {1} {2}" -f $o.objectType, $o.objectId, $o.objectName).Trim())
        $class.SetAttribute('filename', $(if ($o.filePath) { $o.filePath } else { "$($o.objectType)$($o.objectId)" }))
        $class.SetAttribute('line-rate', ([math]::Round($o.lineCoverage / 100, 4)).ToString($ic))
        $class.SetAttribute('branch-rate', '0')
        $class.SetAttribute('complexity', '0')
        $class.AppendChild($doc.CreateElement('methods')) | Out-Null
        $lines = $doc.CreateElement('lines')
        foreach ($l in $o.lines) {
            $line = $doc.CreateElement('line')
            $line.SetAttribute('number', [string]$l.lineNo)
            $line.SetAttribute('hits', [string]$l.hits)
            $line.SetAttribute('branch', 'false')
            $lines.AppendChild($line) | Out-Null
        }
        $class.AppendChild($lines) | Out-Null
        $classes.AppendChild($class) | Out-Null
    }
    $doc.Save($Path)
}

function Write-CoverageMarkdown {
    <#
    .SYNOPSIS
        Writes a Markdown coverage summary (Azure DevOps / GitHub step summary).
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Objects,
        [Parameter(Mandatory)] [object] $Summary,
        [Parameter(Mandatory)] [string] $Path
    )
    $ic = [System.Globalization.CultureInfo]::InvariantCulture
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('# ALbuild Code Coverage Summary').AppendLine()
    [void]$sb.AppendLine([string]::Format($ic, '**Overall line coverage: {0}% ({1} / {2} lines)** across {3} object(s).', $Summary.lineCoverage, $Summary.coveredLines, $Summary.totalExecutableLines, $Summary.objectCount)).AppendLine()
    $worst = @($Objects | Sort-Object lineCoverage | Select-Object -First 10)
    if ($worst.Count -gt 0) {
        [void]$sb.AppendLine('## Lowest-covered objects').AppendLine()
        [void]$sb.AppendLine('| Object | File | Coverage |').AppendLine('|---|---|---:|')
        foreach ($o in $worst) {
            $obj = ("{0} {1} {2}" -f $o.objectType, $o.objectId, $o.objectName).Trim()
            $file = if ($o.filePath) { $o.filePath } else { '-' }
            [void]$sb.AppendLine([string]::Format($ic, '| {0} | {1} | {2}% |', $obj, $file, $o.lineCoverage))
        }
    }
    Set-Content -LiteralPath $Path -Value $sb.ToString() -Encoding UTF8
}