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
}

function ConvertTo-BcLineRangeText {
    <#
    .SYNOPSIS
        Condenses line numbers into ranges: 112,113,114,115,116,117,118,140 -> "112-118, 140".
 
    .DESCRIPTION
        A few hundred individual uncovered line numbers are unreadable. As ranges they show at a glance
        whether a whole procedure is untested or only single branches are missing.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)] [AllowEmptyCollection()] [int[]] $Line)

    $sorted = @($Line | Sort-Object -Unique)
    if ($sorted.Count -eq 0) { return '' }
    $parts = [System.Collections.Generic.List[string]]::new()
    $start = $sorted[0]; $prev = $sorted[0]
    foreach ($n in ($sorted | Select-Object -Skip 1)) {
        if ($n -eq $prev + 1) { $prev = $n; continue }
        $parts.Add($(if ($start -eq $prev) { "$start" } else { "$start-$prev" }))
        $start = $n; $prev = $n
    }
    $parts.Add($(if ($start -eq $prev) { "$start" } else { "$start-$prev" }))
    return ($parts -join ', ')
}

function Write-CoverageHtml {
    <#
    .SYNOPSIS
        Writes a self-contained HTML coverage report for the Azure DevOps Code Coverage tab.
 
    .DESCRIPTION
        ALbuild publishes coverage through the '##vso[codecoverage.publish]' logging command. Unlike the
        PublishCodeCoverageResults task, that command does NOT generate a report - it uploads whatever is
        in the report directory. The directory was created empty, so the tab showed "Code coverage report
        cannot be rendered as report HTML was not found" even though the percentages, the gate and the
        Markdown summary were all correct. This writer fills that directory.
 
        The report answers "where are tests missing?", not just "what is the number": objects are listed
        worst-covered first, each with its uncovered lines condensed to ranges and its annotated source.
 
        Everything is inline - CSS, JavaScript, no images, no fonts, no @import. Azure DevOps serves the
        report from a sandboxed frame where any external request fails silently, and the artifact must
        also be readable offline.
 
    .PARAMETER Objects
        The per-object coverage records (objectType, objectId, objectName, filePath, lineCoverage,
        coveredLines, totalExecutableLines, lines[]).
 
    .PARAMETER Summary
        The overall summary (lineCoverage, coveredLines, totalExecutableLines, objectCount,
        denominatorMode, sourcedObjectCount).
 
    .PARAMETER SourceRoot
        Workspace root used to resolve each object's relative filePath for the annotated source. Without
        it the report is still complete, just without source.
 
    .PARAMETER Path
        The index.html to write.
 
    .PARAMETER MaxObjects
        Object cap. Microsoft documents that the coverage tab stops rendering at roughly 7 MB, which a
        large app with per-test coverage can exceed.
 
    .PARAMETER MaxBytes
        Size budget for the whole document. Annotated source is the main driver of report size, so it is
        the first thing dropped when the budget runs out; only then is the object list truncated. Any
        reduction is stated in the report AND logged as a warning - silently showing a partial report
        would be worse than showing none.
 
    .OUTPUTS
        PSCustomObject: Path, ObjectsRendered, ObjectsTotal, SourceOmitted, Truncated.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Objects,
        [Parameter(Mandatory)] [object] $Summary,
        [string] $SourceRoot,
        [Parameter(Mandatory)] [string] $Path,
        [int] $MaxObjects = 2000,
        [int] $MaxBytes = 4194304
    )

    $ic = [System.Globalization.CultureInfo]::InvariantCulture
    $esc = {
        param($value)
        if ($null -eq $value) { return '' }
        "$value".Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;').Replace('"', '&quot;')
    }
    $pct = { param($value) [string]::Format($ic, '{0:0.##}', [double] $value) }

    $all = @($Objects)
    # Worst first: that is the working order for closing gaps, and it decides what survives truncation.
    $ordered = @($all | Sort-Object @{ Expression = { [double] $_.lineCoverage } }, @{ Expression = { "$($_.objectType) $($_.objectId)" } })
    $rendered = @($ordered | Select-Object -First $MaxObjects)
    $truncated = $all.Count -gt $rendered.Count

    $style = @'
:root{--bg:#faf9f5;--fg:#141413;--mut:#6b6b68;--rule:#e0dfd8;--card:#fff;--ok:#2c6e46;--bad:#a33232;--accent:#0d6154}
@media(prefers-color-scheme:dark){:root{--bg:#121614;--fg:#e7ecea;--mut:#93a29c;--rule:#2b3330;--card:#19201d;--ok:#63b783;--bad:#d97a7a;--accent:#4cbaa4}}
*{box-sizing:border-box}
body{margin:0;padding:24px;background:var(--bg);color:var(--fg);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif}
h1{font-size:20px;margin:0 0 4px}
.sum{display:flex;flex-wrap:wrap;gap:22px;align-items:baseline;padding:16px 18px;background:var(--card);border:1px solid var(--rule);border-radius:5px;margin-bottom:18px}
.big{font-size:30px;font-weight:650;font-variant-numeric:tabular-nums}
.meta{color:var(--mut);font-size:12.5px}
.meta b{color:var(--fg);font-weight:600}
.note{border-left:3px solid var(--bad);background:var(--card);padding:10px 14px;margin-bottom:16px;font-size:13px}
input.f{padding:7px 10px;border:1px solid var(--rule);border-radius:4px;background:var(--card);color:var(--fg);width:280px;margin-bottom:10px;font:inherit}
table{border-collapse:collapse;width:100%;background:var(--card);border:1px solid var(--rule);border-radius:5px;overflow:hidden}
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--rule);font-size:13px;vertical-align:top}
th{background:var(--bg);font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--mut);cursor:pointer;white-space:nowrap;user-select:none}
th:after{content:" \2195";opacity:.35}
td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
td.f{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--mut)}
.bar{display:inline-block;width:110px;height:8px;border-radius:2px;background:var(--bad);overflow:hidden;vertical-align:middle}
.bar>i{display:block;height:100%;background:var(--ok)}
.nofile{color:var(--bad);font-style:italic}
button.tg{border:1px solid var(--rule);background:var(--bg);color:var(--fg);border-radius:3px;cursor:pointer;font:inherit;font-size:11px;padding:0 6px;margin-right:6px}
tr.d>td{background:var(--bg)}
.rng{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-word}
pre.src{margin:8px 0 0;padding:0;overflow-x:auto;font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.45;border-top:1px solid var(--rule)}
pre.src span.l{display:block;white-space:pre}
pre.src i{display:inline-block;width:52px;text-align:right;padding-right:10px;color:var(--mut);font-style:normal;user-select:none}
pre.src .cov{background:rgba(44,110,70,.13)}
pre.src .unc{background:rgba(163,50,50,.16)}
'@


    $script = @'
(function(){
 var f=document.getElementById("flt"),t=document.getElementById("objs");
 if(f)f.addEventListener("input",function(){
   var q=f.value.toLowerCase();
   Array.prototype.forEach.call(t.tBodies[0].rows,function(r){
     if(r.className==="d"){r.hidden=true;return;}
     r.hidden=q&&r.getAttribute("data-k").indexOf(q)<0;
   });
 });
 Array.prototype.forEach.call(t.tHead.rows[0].cells,function(h,i){
   h.addEventListener("click",function(){
     var asc=h.getAttribute("data-asc")!=="1";
     Array.prototype.forEach.call(t.tHead.rows[0].cells,function(x){x.removeAttribute("data-asc")});
     h.setAttribute("data-asc",asc?"1":"0");
     var body=t.tBodies[0],pairs=[];
     for(var r=0;r<body.rows.length;r+=2)pairs.push([body.rows[r],body.rows[r+1]]);
     pairs.sort(function(a,b){
       var x=a[0].cells[i].getAttribute("data-s"),y=b[0].cells[i].getAttribute("data-s");
       if(x===null)x=a[0].cells[i].textContent;
       if(y===null)y=b[0].cells[i].textContent;
       var nx=parseFloat(x),ny=parseFloat(y);
       var c=(!isNaN(nx)&&!isNaN(ny))?nx-ny:String(x).localeCompare(String(y));
       return asc?c:-c;
     });
     pairs.forEach(function(p){body.appendChild(p[0]);body.appendChild(p[1]);});
   });
 });
 t.addEventListener("click",function(e){
   var b=e.target.closest?e.target.closest("button.tg"):null;
   if(!b)return;
   var d=b.closest("tr").nextElementSibling;
   d.hidden=!d.hidden;b.textContent=d.hidden?"+":"-";
 });
})();
'@


    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('<!doctype html><html lang="en"><head><meta charset="utf-8">')
    [void]$sb.AppendLine('<meta name="viewport" content="width=device-width,initial-scale=1">')
    [void]$sb.AppendLine('<title>ALbuild Code Coverage</title>')
    [void]$sb.AppendLine('<style>').Append($style).AppendLine('</style></head><body>')
    [void]$sb.AppendLine('<h1>ALbuild Code Coverage</h1>')

    # Header. The denominator mode belongs here: 'CoveredOnly' means the denominator came from the
    # coverage export itself and the percentage is not comparable to a source-derived one.
    [void]$sb.AppendLine('<div class="sum">')
    [void]$sb.AppendLine([string]::Format($ic, '<span class="big">{0}%</span>', (& $pct $Summary.lineCoverage)))
    [void]$sb.AppendLine([string]::Format($ic, '<span class="meta"><b>{0}</b> / {1} lines covered</span>', $Summary.coveredLines, $Summary.totalExecutableLines))
    [void]$sb.AppendLine([string]::Format($ic, '<span class="meta"><b>{0}</b> object(s)</span>', $Summary.objectCount))
    $mode = & $esc $Summary.denominatorMode
    $sourced = if ($Summary.PSObject.Properties.Name -contains 'sourcedObjectCount') { $Summary.sourcedObjectCount } else { '' }
    [void]$sb.AppendLine([string]::Format($ic, '<span class="meta">denominator <b>{0}</b>{1}</span>', $mode, $(if ("$sourced" -ne '') { " ($sourced from source)" } else { '' })))
    [void]$sb.AppendLine('</div>')

    if ($truncated) {
        [void]$sb.AppendLine([string]::Format($ic,
                '<div class="note">Showing the {0} lowest-covered of {1} objects. The Azure DevOps coverage tab stops rendering large reports, so the list was capped.</div>',
                $rendered.Count, $all.Count))
    }

    [void]$sb.AppendLine('<input id="flt" class="f" type="text" placeholder="Filter objects...">')
    [void]$sb.AppendLine('<table id="objs"><thead><tr><th>Object</th><th>Name</th><th>File</th><th>Lines</th><th>Coverage</th></tr></thead><tbody>')

    # Rows first, source afterwards: if the budget runs out, every object still appears in the table and
    # only the source is missing.
    $details = @{}
    foreach ($o in $rendered) {
        $objLabel = ("{0} {1}" -f $o.objectType, $o.objectId).Trim()
        $name = & $esc $o.objectName
        $file = if ($o.filePath) { & $esc $o.filePath } else { '<span class="nofile">no source file</span>' }
        $cov = [double] $o.lineCoverage
        $key = ("$objLabel $($o.objectName) $($o.filePath)").ToLowerInvariant()
        [void]$sb.AppendLine([string]::Format($ic,
                '<tr class="o" data-k="{0}"><td><button class="tg" type="button">+</button>{1}</td><td>{2}</td><td class="f">{3}</td><td class="n" data-s="{4}">{4}/{5}</td><td class="n" data-s="{6}"><span class="bar"><i style="width:{6}%"></i></span> {6}%</td></tr>',
            (& $esc $key), (& $esc $objLabel), $name, $file, $o.coveredLines, $o.totalExecutableLines, (& $pct $cov)))
        $uncovered = @($o.lines | Where-Object { [int] $_.hits -eq 0 } | ForEach-Object { [int] $_.lineNo })
        $ranges = ConvertTo-BcLineRangeText -Line $uncovered
        $body = if ($ranges) { '<b>Uncovered lines:</b> <span class="rng">' + (& $esc $ranges) + '</span>' } else { 'Fully covered.' }
        $details[$objLabel] = @{ Row = $sb.Length; Object = $o; Body = $body }
        [void]$sb.AppendLine('<tr class="d" hidden><td colspan="5">' + $body + '__SRC_' + ($details.Count - 1) + '__</td></tr>')
    }
    [void]$sb.AppendLine('</tbody></table>')
    [void]$sb.AppendLine('<script>').Append($script).AppendLine('</script></body></html>')

    # Annotated source, appended into the placeholders while the size budget allows it.
    $html = $sb.ToString()
    $sourceOmitted = 0
    $index = -1
    foreach ($o in $rendered) {
        $index++
        $token = '__SRC_' + $index + '__'
        $snippet = ''
        $srcFile = $null
        if ($o.filePath -and $SourceRoot) { $srcFile = Join-Path $SourceRoot ($o.filePath -replace '/', [System.IO.Path]::DirectorySeparatorChar) }
        if ($srcFile -and (Test-Path -LiteralPath $srcFile) -and $html.Length -lt $MaxBytes) {
            $status = @{}
            foreach ($l in @($o.lines)) { $status[[int] $l.lineNo] = ([int] $l.hits -gt 0) }
            $srcSb = [System.Text.StringBuilder]::new()
            [void]$srcSb.Append('<pre class="src">')
            $no = 0
            foreach ($text in (Get-Content -LiteralPath $srcFile -ErrorAction SilentlyContinue)) {
                $no++
                $cls = if ($status.ContainsKey($no)) { if ($status[$no]) { ' cov' } else { ' unc' } } else { '' }
                [void]$srcSb.Append('<span class="l' + $cls + '"><i>' + $no + '</i>' + (& $esc $text) + '</span>')
            }
            [void]$srcSb.Append('</pre>')
            if ($html.Length + $srcSb.Length -lt $MaxBytes) { $snippet = $srcSb.ToString() } else { $sourceOmitted++ }
        }
        elseif ($srcFile) { $sourceOmitted++ }
        $html = $html.Replace($token, $snippet)
    }

    if ($sourceOmitted -gt 0) {
        $html = $html.Replace('<input id="flt"',
            ('<div class="note">Annotated source omitted for ' + $sourceOmitted +
            ' object(s) to keep the report within the size the coverage tab can render.</div><input id="flt"'))
    }

    if ($truncated -or $sourceOmitted -gt 0) {
        Write-ALbuildLog -Level Warning ("Coverage HTML report reduced to stay renderable: " +
            "$($rendered.Count) of $($all.Count) object(s) listed, annotated source omitted for $sourceOmitted. " +
            'The percentages and the gate are unaffected.')
    }

    $parent = Split-Path -Parent $Path
    if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
    # Explicit UTF-8 BOM: PowerShell 7's -Encoding UTF8 writes no BOM while 5.1's does, and the report
    # must render umlauts in object and file names identically from either edition.
    [System.IO.File]::WriteAllText($Path, $html, (New-Object System.Text.UTF8Encoding($true)))

    return [PSCustomObject]@{
        Path            = $Path
        ObjectsRendered = $rendered.Count
        ObjectsTotal    = $all.Count
        SourceOmitted   = $sourceOmitted
        Truncated       = $truncated
    }
}