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(
        # AllowEmptyCollection: a mandatory parameter rejects an empty array, and "no measurable object"
        # is a legitimate result - a repository whose apps are all test apps, an excluded project, a
        # workspace without AL source. Refusing to write the report turned that into a failed release
        # build (ERiC 27809). An empty report is the honest answer; 0 objects at 0% is one.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [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')
        # One <method> per measured procedure, so ReportGenerator and Azure DevOps can show coverage by method.
        $hitsByLine = @{}
        foreach ($l in $o.lines) { $hitsByLine[[int]$l.lineNo] = [int]$l.hits }
        $methods = $doc.CreateElement('methods')
        if ($o.PSObject.Properties.Name -contains 'procedures') {
            foreach ($p in @($o.procedures)) {
                if (-not $p.measured) { continue }
                $method = $doc.CreateElement('method')
                $method.SetAttribute('name', [string]$p.name)
                $method.SetAttribute('signature', '')
                $method.SetAttribute('line-rate', ([math]::Round($p.lineCoverage / 100, 4)).ToString($ic))
                $method.SetAttribute('branch-rate', '0')
                $method.SetAttribute('complexity', '0')
                $methodLines = $doc.CreateElement('lines')
                foreach ($n in @($hitsByLine.Keys | Where-Object { $_ -ge [int]$p.line -and $_ -le [int]$p.endLine } | Sort-Object)) {
                    $ml = $doc.CreateElement('line')
                    $ml.SetAttribute('number', [string]$n)
                    $ml.SetAttribute('hits', [string]$hitsByLine[$n])
                    $ml.SetAttribute('branch', 'false')
                    $methodLines.AppendChild($ml) | Out-Null
                }
                $method.AppendChild($methodLines) | Out-Null
                $methods.AppendChild($method) | Out-Null
            }
        }
        $class.AppendChild($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).
 
    .DESCRIPTION
        Overall figure, test quality, lowest-covered objects, the procedures with uncovered lines (the worst
        ones with their uncovered source), and the lines outside the percentage: [NonDebuggable] bodies and
        albuild.json exclusions, each listed rather than silently dropped.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')]
    [CmdletBinding()]
    param(
        # AllowEmptyCollection: see Write-CoberturaXml - an empty measurement must still produce a report.
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Objects,
        [Parameter(Mandatory)] [object] $Summary,
        [Parameter(Mandatory)] [string] $Path,
        [object] $TestQuality,
        [AllowEmptyCollection()] [object[]] $Unmeasured = @(),
        [AllowEmptyCollection()] [object[]] $Excluded = @(),
        [string] $SourceRoot,
        [int] $MaxProcedures = 30,
        [int] $MaxSourceProcedures = 10,
        [int] $MaxSourceLines = 20
    )
    $ic = [System.Globalization.CultureInfo]::InvariantCulture
    $has = { param($o, $n) $o -and ($o.PSObject.Properties.Name -contains $n) }
    $cell = { param($v) "$v".Replace('|', '\|') }
    $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()
    if ((& $has $Summary 'objectsNeverExecuted') -and [int] $Summary.objectsNeverExecuted -gt 0) {
        [void]$sb.AppendLine([string]::Format($ic, '**{0} object(s) were never executed by any test** - not weakly covered, never reached.', $Summary.objectsNeverExecuted)).AppendLine()
    }
    if ((& $has $Summary 'unmeasuredProcedures') -and [int] $Summary.unmeasuredProcedures -gt 0) {
        $state = if ([bool]$Summary.nonDebuggableIncluded) { 'counted in the percentage as uncovered' } else { 'not in the percentage' }
        [void]$sb.AppendLine([string]::Format($ic, '**Not measurable: {0} [NonDebuggable] procedure(s), {1} line(s)** - {2}. Business Central never reports these lines.', $Summary.unmeasuredProcedures, $Summary.unmeasuredLines, $state)).AppendLine()
    }
    if ((& $has $Summary 'excludedLines') -and [int] $Summary.excludedLines -gt 0) {
        [void]$sb.AppendLine([string]::Format($ic, '**Excluded: {0} line(s) from {1} albuild.json entr(y/ies)** - not in the percentage, each with its reason below.', $Summary.excludedLines, $Summary.exclusionCount)).AppendLine()
    }
    if ($null -ne $TestQuality) {
        # Directly under the percentage, because it qualifies the percentage.
        $q = $TestQuality.Summary
        [void]$sb.AppendLine([string]::Format($ic, '**Test quality: {0} of {1} test(s) assert (score {2}).**', $q.testsWithAssertions, $q.testCount, $q.qualityScore))
        if ([int] $q.testsWithoutAssertions -gt 0 -or [int] $q.emptyTests -gt 0) {
            [void]$sb.AppendLine([string]::Format($ic, ' {0} test(s) without assertions, {1} empty. Those tests raise coverage without proving anything.', $q.testsWithoutAssertions, $q.emptyTests))
        }
        [void]$sb.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}% |', (& $cell $obj), (& $cell $file), $o.lineCoverage))
        }
        [void]$sb.AppendLine()
    }

    # Procedures with uncovered lines, most uncovered first. Never-executed objects are left to the object
    # table above: every one of their lines is uncovered, listing them procedure by procedure adds nothing.
    $gaps = @(foreach ($o in $Objects) {
            if (-not (& $has $o 'procedures') -or [bool]$o.neverExecuted) { continue }
            foreach ($p in @($o.procedures)) {
                if (@($p.uncoveredLines).Count -gt 0) { [PSCustomObject]@{ Object = $o; Procedure = $p; Count = @($p.uncoveredLines).Count } }
            }
        })
    $gaps = @($gaps | Sort-Object @{ Expression = { $_.Count }; Descending = $true }, @{ Expression = { "$($_.Object.objectType) $($_.Object.objectId) $($_.Procedure.line)" } })
    if ($gaps.Count -gt 0) {
        [void]$sb.AppendLine('## Procedures with uncovered lines').AppendLine()
        [void]$sb.AppendLine('| Object | Procedure | Covered | Uncovered lines |').AppendLine('|---|---|---:|---|')
        foreach ($g in @($gaps | Select-Object -First $MaxProcedures)) {
            $obj = ("{0} {1} {2}" -f $g.Object.objectType, $g.Object.objectId, $g.Object.objectName).Trim()
            [void]$sb.AppendLine([string]::Format($ic, '| {0} | {1} | {2} / {3} | {4} |', (& $cell $obj), (& $cell $g.Procedure.name),
                    $g.Procedure.coveredLines, $g.Procedure.executableLines, (ConvertTo-BcLineRangeText -Line @($g.Procedure.uncoveredLines))))
        }
        if ($gaps.Count -gt $MaxProcedures) { [void]$sb.AppendLine().AppendLine([string]::Format($ic, '{0} more procedure(s) with uncovered lines are in the HTML report and coverage-summary.json.', $gaps.Count - $MaxProcedures)) }
        [void]$sb.AppendLine()

        if ($SourceRoot) {
            $sourceCache = @{}
            $shown = 0
            foreach ($g in $gaps) {
                if ($shown -ge $MaxSourceProcedures) { break }
                if (-not $g.Object.filePath) { continue }
                $file = Join-Path $SourceRoot ($g.Object.filePath -replace '/', [System.IO.Path]::DirectorySeparatorChar)
                if (-not $sourceCache.ContainsKey($file)) { $sourceCache[$file] = @(if (Test-Path -LiteralPath $file) { Get-Content -LiteralPath $file }) }
                $text = $sourceCache[$file]
                if ($text.Count -eq 0) { continue }
                if ($shown -eq 0) { [void]$sb.AppendLine('### Uncovered source').AppendLine() }
                $shown++
                $obj = ("{0} {1} {2}" -f $g.Object.objectType, $g.Object.objectId, $g.Object.objectName).Trim()
                [void]$sb.AppendLine([string]::Format($ic, '**{0} - {1}** ({2})', $obj, $g.Procedure.name, $g.Object.filePath)).AppendLine().AppendLine('```al')
                foreach ($n in @($g.Procedure.uncoveredLines | Select-Object -First $MaxSourceLines)) {
                    if ($n -le $text.Count) { [void]$sb.AppendLine([string]::Format($ic, '{0,5}: {1}', $n, $text[$n - 1].Trim())) }
                }
                if (@($g.Procedure.uncoveredLines).Count -gt $MaxSourceLines) { [void]$sb.AppendLine([string]::Format($ic, ' ... {0} more', @($g.Procedure.uncoveredLines).Count - $MaxSourceLines)) }
                [void]$sb.AppendLine('```').AppendLine()
            }
        }
    }

    $nd = @($Unmeasured)
    if ($nd.Count -gt 0) {
        $state = if ([bool]$Summary.nonDebuggableIncluded) { 'counted as uncovered' } else { 'not in the percentage' }
        [void]$sb.AppendLine("## Not measurable ([NonDebuggable], $state)").AppendLine()
        [void]$sb.AppendLine('| Object | Procedure | Lines |').AppendLine('|---|---|---:|')
        foreach ($u in $nd) {
            $obj = ("{0} {1} {2}" -f $u.objectType, $u.objectId, $u.objectName).Trim()
            [void]$sb.AppendLine([string]::Format($ic, '| {0} | {1} | {2} |', (& $cell $obj), (& $cell $u.procedure), $u.lines))
        }
        [void]$sb.AppendLine()
    }

    $ex = @($Excluded)
    if ($ex.Count -gt 0) {
        [void]$sb.AppendLine('## Excluded (albuild.json coverage.exclusions)').AppendLine()
        [void]$sb.AppendLine('| Object | Procedure | Lines | Reason |').AppendLine('|---|---|---|---|')
        foreach ($x in $ex) {
            $obj = ("{0} {1} {2}" -f $x.objectType, $x.objectId, $x.objectName).Trim()
            $proc = if ($x.procedure) { $x.procedure } else { '-' }
            [void]$sb.AppendLine([string]::Format($ic, '| {0} | {1} | {2} | {3} |', (& $cell $obj), (& $cell $proc), (ConvertTo-BcLineRangeText -Line @($x.lines)), (& $cell $x.reason)))
        }
        [void]$sb.AppendLine()
    }
    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". Its one rule: only the
        gaps are painted. Colour marks the lines no test ran - in each object's gap map, in the excerpt of
        those lines, and in the gutter of the annotated source - and nothing else gets it. Objects are listed
        worst-covered first; each row is the <summary> of its own <details> and opens in place.
 
        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. The stylesheet is Resources/Coverage/coverage-report.css, read from the
        module at write time. It is self-contained and does not use the shared report base the call graph
        uses, because that one is built on custom properties (see below). Light theme only.
 
        THE TAB SANITIZES THE PAGE. Azure DevOps serves index.html through a server-side sanitizer
        (measured on build 29173 by diffing the served page with ours): every <script> and every id
        attribute is removed, and the CSS is re-serialised so that only allow-listed properties with
        literal values survive - no custom properties, var(), color-mix(), grid, gap, transform, pseudo-
        element or :is() rules, and no @media (prefers-*) blocks. The 2.67 redesign was built on all of
        that and arrived in the tab as unstyled serif text. Hence the rules this writer follows:
          - collapsing uses <details>/<summary>; no in-page '#' link, because the ids it needs are gone
          - each object row IS its disclosure; there is no separate table to jump from
          - line numbers are text, gutter marks and gap-map segments are elements with inline widths; no
            CSS counter, generated content or gradient carries information
          - the script only ADDS things in the downloaded artifact (filter box, sort buttons) and creates
            its own controls, so nothing that cannot work is ever visible
          - source sits in its own horizontally scrollable box inside a nested, closed <details>
          - every fact carries its own words, so the page still reads correctly with partial styling
 
    .PARAMETER Objects
        The per-object coverage records (objectType, objectId, objectName, filePath, lineCoverage,
        coveredLines, totalExecutableLines, lines[]; optionally neverExecuted, procedures, continuations,
        unmeasuredLines, excludedLines). A line record with status 'Partial' or partial = $true is shown
        as partly run; without such a flag no line is.
 
    .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 and the
        app name in the header. Without it the report is still complete, just without source.
 
    .PARAMETER Path
        The index.html to write.
 
    .PARAMETER MaxObjects
        Object cap. A large app with per-test coverage can otherwise exceed what the coverage tab renders.
 
    .PARAMETER MaxBytes
        Hard cap on the finished file in UTF-8 bytes (BOM included). Annotated source is the main driver of
        report size, so it only gets what is left after everything that is always shown; the object list is
        capped by MaxObjects. Any reduction is stated in the report AND logged as a warning - silently
        showing a partial report would be worse than showing none.
 
        Microsoft documents that the tab stops rendering "near 7 MB". Measured, it stops far earlier and
        shows NOTHING, not a partial report: Banking quality gate 28073 (4,283,410 bytes) renders, 28420
        (4,313,100 bytes) and every larger one stay blank. The default of 4,000,000 bytes keeps ~280 KB
        below the largest size seen to render.
 
    .PARAMETER Title
        What the report is about, shown as "Code coverage - <Title>". Omitted, it is the name from the
        app.json that owns the measured objects (below SourceRoot); without one the header reads
        "Code coverage".
 
    .PARAMETER Context
        The mono context line under the title. Omitted, it is the app version plus the build number and
        branch when the pipeline provides them.
 
    .OUTPUTS
        PSCustomObject: Path, ObjectsRendered, ObjectsTotal, SourceOmitted, SourceMissing, Truncated, Bytes.
    #>

    [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,
        [object] $TestQuality,
        [AllowEmptyCollection()] [object[]] $Unmeasured = @(),
        [AllowEmptyCollection()] [object[]] $Excluded = @(),
        [int] $MaxObjects = 2000,
        [int] $MaxBytes = 4000000,
        [string] $Title,
        [string] $Context
    )

    $ic = [System.Globalization.CultureInfo]::InvariantCulture
    $nl = [Environment]::NewLine
    $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) }
    $num = { param($value) ([long] $value).ToString('N0', $ic) }
    # One signal scale for every ALbuild report: bad < 60 <= warn < 80 <= good (the call graph's test reach too).
    $band = { param($value) if ([double] $value -lt 60) { 'bad' } elseif ([double] $value -lt 80) { 'warn' } else { 'good' } }
    $plural = { param($n, [string] $one, [string] $many) if ([long] $n -eq 1) { $one } else { $many } }
    $prop = { param($obj, $n) if ($obj -and ($obj.PSObject.Properties.Name -contains $n)) { $obj.$n } else { $null } }
    $has = { param($obj, $n) $obj -and ($obj.PSObject.Properties.Name -contains $n) }
    # "0% covered" and "no test ever reached this object" look identical in a percentage but mean different
    # things: the second usually means a whole feature has no test at all. Marked so it can be told apart.
    $isNever = { param($obj) (& $has $obj 'neverExecuted') -and [bool] $obj.neverExecuted }
    $labelOf = { param($obj) ("{0} {1}" -f $obj.objectType, $obj.objectId).Trim() }
    $iconWarn = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l9.5 17h-19z"/><path d="M12 10v4M12 17.5v.01"/></svg>'
    $iconApp = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 4v16"/><path d="M10 6h10M10 10h7M10 14h10M10 18h6"/></svg>'

    $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
    $root = $SourceRoot
    $sourceFileOf = {
        param($obj)
        if (-not $obj.filePath -or -not $root) { return $null }
        Join-Path $root ($obj.filePath -replace '/', [System.IO.Path]::DirectorySeparatorChar)
    }
    # Paths relative to the workspace root, the way Convert-BcCodeCoverage writes object paths: an
    # absolute agent path (D:\devops-agent\...\_work\74\s\...) says nothing to the reader.
    $relPathOf = {
        param([string] $file)
        if ($file -and $root -and $file.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
            return $file.Substring($root.Length).TrimStart('\', '/').Replace('\', '/')
        }
        $file
    }
    # Chevron of a disclosure: closed and open glyph, CSS shows one; no generated content survives the tab.
    # Built from code points: Windows PowerShell 5.1 reads this BOM-less file as ANSI.
    $chevron = '<span class=cv aria-hidden=true><b class=c1>' + [char] 0x25B8 + '</b><b class=c2>' + [char] 0x25BE + '</b></span>'

    # Header context. The writer gets no app name from its caller, but the app.json that owns the measured
    # files has it. Best effort: a report without a name is still a complete report.
    if (-not $Title -or -not $Context) {
        $apps = [System.Collections.Generic.List[object]]::new()
        $seenDirs = @{}
        $seenApps = @{}
        foreach ($o in $rendered) {
            $file = & $sourceFileOf $o
            if (-not $file) { continue }
            $dir = Split-Path -Parent $file
            if ($seenDirs.ContainsKey($dir)) { continue }
            $seenDirs[$dir] = $true
            try { $ctx = Resolve-BcAlAppContext -Path $file } catch { continue }
            if (-not $ctx.AppFolder -or $seenApps.ContainsKey($ctx.AppFolder)) { continue }
            $seenApps[$ctx.AppFolder] = $true
            $appName = & $prop $ctx.Manifest 'name'
            if ($appName) { $apps.Add([PSCustomObject]@{ Name = [string] $appName; Version = [string] (& $prop $ctx.Manifest 'version') }) }
        }
        if (-not $Title -and $apps.Count -gt 0) {
            $names = @($apps | Select-Object -First 3 | ForEach-Object { $_.Name })
            $Title = $names -join ', '
            if ($apps.Count -gt 3) { $Title += [string]::Format($ic, ' and {0} more', $apps.Count - 3) }
        }
        if (-not $Context) {
            $parts = [System.Collections.Generic.List[string]]::new()
            if ($apps.Count -eq 1 -and $apps[0].Version) { $parts.Add($apps[0].Version) }
            if ($env:BUILD_BUILDNUMBER -and -not $parts.Contains($env:BUILD_BUILDNUMBER)) { $parts.Add('build ' + $env:BUILD_BUILDNUMBER) }
            if ($env:BUILD_SOURCEBRANCHNAME) { $parts.Add($env:BUILD_SOURCEBRANCHNAME) }
            $Context = $parts -join ' / '
        }
    }
    $heading = 'Code coverage'
    if ($Title) { $heading += ' &middot; ' + (& $esc $Title) }
    $subParts = @()
    if ($Context) { $subParts += (& $esc $Context) }
    $subParts += 'line coverage'

    # The coverage stylesheet, embedded so the report stays one offline file. Self-contained on purpose:
    # the shared report base is built on custom properties, which the tab's sanitizer deletes.
    $resourceRoot = Join-Path (Split-Path -Parent $PSScriptRoot) 'Resources'
    $css = [System.IO.File]::ReadAllText((Join-Path $resourceRoot 'Coverage/coverage-report.css'))
    $css = $css.Replace('</style', '<\/style')

    # Pure enhancement, and it has to stay that way: the tab strips every script, so this only runs when
    # the artifact is opened in a browser. It creates its own controls, so nothing that cannot work is ever
    # visible, and the rows already arrive sorted worst first. It finds everything by class: the tab
    # strips ids too, and nothing may depend on them.
    $script = @'
(function(){
 var sec=document.querySelector("section.objs"),cols=sec&&sec.querySelector(".cols");
 if(!cols)return;
 var rows=[].slice.call(sec.querySelectorAll("details.ob"));
 if(!rows.length)return;
 var box=document.createElement("label"),inp=document.createElement("input");
 box.className="flt";inp.type="search";inp.placeholder="Filter objects";
 inp.setAttribute("aria-label","Filter objects by name, type, ID or path");
 box.appendChild(inp);sec.insertBefore(box,cols);
 var keys=rows.map(function(r){return r.querySelector("summary").textContent.toLowerCase();});
 inp.addEventListener("input",function(){
  var q=inp.value.toLowerCase();
  rows.forEach(function(r,i){r.hidden=!!q&&keys[i].indexOf(q)<0;});
 });
 function n(r,s){var e=r.querySelector(s);return e?parseFloat(e.textContent.replace(/[^\d.]/g,""))||0:0;}
 var val={pc:function(r){return n(r,".pc");},nm:function(r){return r.querySelector(".nm").textContent.toLowerCase();},
  ln:function(r){return n(r,".ln b");},nr:function(r){return n(r,".nr b");}};
 cols.removeAttribute("aria-hidden");
 var heads=[];
 [].slice.call(cols.querySelectorAll("[data-sort]")).forEach(function(h){
  var f=val[h.getAttribute("data-sort")],b=document.createElement("button"),t=h.textContent;
  b.type="button";b.textContent=t;b.title="Sort by "+t.toLowerCase();
  h.textContent="";h.appendChild(b);h.setAttribute("role","columnheader");
  heads.push({h:h,b:b,t:t});
  b.addEventListener("click",function(){
   var asc=h.getAttribute("aria-sort")!=="ascending";
   heads.forEach(function(x){x.h.removeAttribute("aria-sort");x.b.textContent=x.t;});
   h.setAttribute("aria-sort",asc?"ascending":"descending");
   b.textContent=t+(asc?" \u2191":" \u2193");
   rows.slice().sort(function(x,y){var a=f(x),c=f(y),d=a<c?-1:a>c?1:0;return asc?d:-d;}).forEach(function(r){sec.appendChild(r);});
  });
 });
})();
'@


    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('<!doctype html>').AppendLine('<html lang="en">').AppendLine('<head>').AppendLine('<meta charset="utf-8">')
    [void]$sb.AppendLine('<meta name="viewport" content="width=device-width, initial-scale=1">')
    [void]$sb.AppendLine('<title>' + $heading + '</title>')
    [void]$sb.Append('<style>').Append($nl).Append($css).AppendLine('</style>').AppendLine('</head>').AppendLine('<body>')
    [void]$sb.AppendLine('<header class="hdr"><div class="titles"><h1>' + $iconApp + $heading + '</h1><div class="sub">' + ($subParts -join ' &middot; ') + '</div></div></header>')

    # Summary line. Each fact is a span with its own words, so it reads correctly with no styling at all.
    # 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.
    $overall = [double] $Summary.lineCoverage
    [void]$sb.AppendLine('<section class="summary" aria-label="Coverage summary">')
    [void]$sb.AppendLine([string]::Format($ic, '<span><b>{1}%</b> of lines covered <span class="meter" aria-hidden="true"><i class="{0}" style="width:{1}%"></i></span></span>',
            (& $band $overall), (& $pct $overall)))
    [void]$sb.AppendLine([string]::Format($ic, '<span><b>{0}</b> of {1} executable lines</span>', (& $num $Summary.coveredLines), (& $num $Summary.totalExecutableLines)))
    [void]$sb.AppendLine([string]::Format($ic, '<span><b>{0}</b> {1}</span>', (& $num $Summary.objectCount), (& $plural $Summary.objectCount 'object' 'objects')))
    $neverCount = 0
    if (& $has $Summary 'objectsNeverExecuted') { $neverCount = [int] $Summary.objectsNeverExecuted }
    if ($neverCount -gt 0) { [void]$sb.AppendLine([string]::Format($ic, '<span><b>{0}</b> never run</span>', $neverCount)) }
    $q = $null
    if ($null -ne $TestQuality) {
        $q = $TestQuality.Summary
        [void]$sb.AppendLine([string]::Format($ic, '<span><b>{0}</b> of {1} {2} assert</span>', (& $num $q.testsWithAssertions), (& $num $q.testCount), (& $plural $q.testCount 'test' 'tests')))
    }
    $ndList = @($Unmeasured)
    $exList = @($Excluded)
    $ndIncluded = (& $has $Summary 'nonDebuggableIncluded') -and [bool] $Summary.nonDebuggableIncluded
    $outsideLines = 0
    if (-not $ndIncluded -and (& $has $Summary 'unmeasuredLines')) { $outsideLines += [int] $Summary.unmeasuredLines }
    if (& $has $Summary 'excludedLines') { $outsideLines += [int] $Summary.excludedLines }
    if ($outsideLines -gt 0) {
        [void]$sb.AppendLine([string]::Format($ic, '<span><b>{0}</b> {1} outside the percentage</span>', (& $num $outsideLines), (& $plural $outsideLines 'line' 'lines')))
    }
    $mode = "$($Summary.denominatorMode)"
    $sourced = & $prop $Summary 'sourcedObjectCount'
    if ("$sourced" -ne '') {
        [void]$sb.AppendLine([string]::Format($ic, '<span class="end mono">{0} of {1} {2} measured from source</span>', (& $num $sourced), (& $num $Summary.objectCount), (& $plural $Summary.objectCount 'object' 'objects')))
    }
    else { [void]$sb.AppendLine('<span class="end mono">denominator ' + (& $esc $mode) + '</span>') }
    [void]$sb.AppendLine('</section>')
    [void]$sb.AppendLine('<main class="page">')

    # ---- overview ----
    [void]$sb.AppendLine('<div class="overview">')

    # Objects by coverage: a 100% stacked bar in fixed order; the key carries the numbers in words.
    $counts = @{ bad = 0; warn = 0; good = 0 }
    foreach ($o in $all) { $counts[(& $band $o.lineCoverage)]++ }
    # Sections are named by aria-label, not aria-labelledby: the tab strips the ids a reference needs.
    [void]$sb.AppendLine('<section class="panel" aria-label="Objects by coverage"><h2 class="eyebrow">Objects by coverage</h2>')
    if ($all.Count -eq 0) {
        [void]$sb.AppendLine('<p class="headline">No measurable object</p><p class="note">Every app below the workspace is a test app, is excluded, or has no executable AL line.</p></section>')
    }
    else {
        $neverText = ''
        if ($neverCount -gt 0) { $neverText = [string]::Format($ic, ', {0} of them never run', $neverCount) }
        [void]$sb.AppendLine([string]::Format($ic, '<p class="headline">{0} of {1} {2} below 60%{3}</p>',
                $counts.bad, $all.Count, (& $plural $all.Count 'object is' 'objects are'), $neverText))
        $segments = foreach ($b in 'bad', 'warn', 'good') { if ($counts[$b] -gt 0) { [string]::Format($ic, '<i class="{0}" style="flex-grow:{1}"></i>', $b, $counts[$b]) } }
        [void]$sb.AppendLine('<div class="dist" aria-hidden="true">' + (@($segments) -join '') + '</div>')
        [void]$sb.AppendLine([string]::Format($ic, '<ul class="dist-key"><li><i class="sw bad"></i><b>{0}</b> below 60%</li><li><i class="sw warn"></i><b>{1}</b> 60 to 79%</li><li><i class="sw good"></i><b>{2}</b> 80% and above</li></ul></section>',
                $counts.bad, $counts.warn, $counts.good))
    }

    # Largest gaps: where to write the next test, by size, which the percentage order cannot say. Never-run
    # objects are left out, as in the Markdown summary: every one of their lines is a gap.
    $gaps = @(foreach ($o in $rendered) {
            if (-not (& $has $o 'procedures') -or (& $isNever $o)) { continue }
            foreach ($p in @($o.procedures)) {
                if ($p -and @($p.uncoveredLines).Count -gt 0) { [PSCustomObject]@{ Object = $o; Procedure = $p; Count = @($p.uncoveredLines).Count } }
            }
        })
    $gaps = @($gaps | Sort-Object @{ Expression = { $_.Count }; Descending = $true }, @{ Expression = { "$($_.Object.objectType) $($_.Object.objectId) $($_.Procedure.line)" } })
    # Text only, no link: the tab strips the ids a fragment link would need. Object and type/ID name the row.
    [void]$sb.AppendLine('<section class="panel" aria-label="Largest gaps"><h2 class="eyebrow">Largest gaps</h2>')
    if ($gaps.Count -gt 0) {
        [void]$sb.Append('<p class="headline">Procedures with the most lines no test ran</p><ol class="gaps-list">')
        foreach ($g in @($gaps | Select-Object -First 5)) {
            [void]$sb.Append([string]::Format($ic, '<li><span class="gt"><span class="pn">{0}</span><span class="on">{1} &middot; {2}</span></span><span class="k"><b>{3}</b> not run</span></li>',
                    (& $esc $g.Procedure.name), (& $esc $g.Object.objectName), (& $esc (& $labelOf $g.Object)), (& $num $g.Count)))
        }
        [void]$sb.AppendLine('</ol></section>')
    }
    elseif ($neverCount -gt 0) { [void]$sb.AppendLine('<p class="headline">Only never-run objects have lines no test ran</p></section>') }
    else { [void]$sb.AppendLine('<p class="headline">No procedure has a line no test ran</p></section>') }

    # Test quality qualifies the percentage: a [Test] method that asserts nothing still covers every line it
    # touches, so coverage on its own overstates what the suite proves. The band sits on the meter fill only.
    if ($null -ne $q) {
        $score = [double] $q.qualityScore
        [void]$sb.AppendLine('<section class="panel" aria-label="Test quality"><h2 class="eyebrow">Test quality</h2>')
        [void]$sb.AppendLine([string]::Format($ic, '<p class="headline">{1} of {2} {3} assert <span class="meter" aria-hidden="true"><i class="{0}" style="width:{4}%"></i></span></p>',
                (& $band $score), (& $num $q.testsWithAssertions), (& $num $q.testCount), (& $plural $q.testCount 'test' 'tests'), (& $pct $score)))
        [void]$sb.AppendLine([string]::Format($ic, '<div class="stats"><div class="stat"><b>{0}</b><span>assertions per test</span></div><div class="stat"><b>{1}</b><span>tests without assertions</span></div><div class="stat"><b>{2}</b><span>empty tests</span></div></div>',
                (& $pct $q.assertionDensity), $q.testsWithoutAssertions, $q.emptyTests))
        [void]$sb.AppendLine('<p class="note">Coverage says which lines ran, not that a wrong result would have been caught. A test that asserts nothing still covers every line it touches.</p>')
        $weak = @($TestQuality.Tests | Where-Object { $_ -and -not $_.hasAssertions } | Sort-Object codeunit, testName)
        if ($weak.Count -gt 0) {
            [void]$sb.Append([string]::Format($ic, '<details class="list weak"><summary>{0} {1} nothing</summary><div class="table-wrap"><table class="r-table"><thead><tr><th scope="col">Test codeunit</th><th scope="col">Test</th><th scope="col">File</th></tr></thead><tbody>',
                    $weak.Count, (& $plural $weak.Count 'test asserts' 'tests assert')))
            foreach ($t in $weak) {
                $issue = ''
                if (@($t.issues) -contains 'Empty') { $issue = ' <span class="chip warn">Empty</span>' }
                [void]$sb.Append([string]::Format($ic, '<tr><td>{0}</td><td>{1}{2}</td><td class="mono">{3}:{4}</td></tr>',
                        (& $esc $t.codeunit), (& $esc $t.testName), $issue, (& $esc (& $relPathOf ([string] $t.filePath))), $t.line))
            }
            [void]$sb.AppendLine('</tbody></table></div></details>')
        }
        [void]$sb.AppendLine('</section>')
    }

    # Outside the percentage: always present, so "nothing excluded" can be told from "the report doesn't
    # say". Lines outside the percentage are listed, never dropped silently.
    [void]$sb.AppendLine('<section class="panel outside" aria-label="Outside the percentage"><h2 class="eyebrow">Outside the percentage</h2>')
    if ($ndList.Count -eq 0 -and $exList.Count -eq 0) { $outHead = 'Every executable line counts toward ' + (& $pct $overall) + '%' }
    elseif ($outsideLines -gt 0) { $outHead = [string]::Format($ic, '{0} {1} not in the percentage', (& $num $outsideLines), (& $plural $outsideLines 'line is' 'lines are')) }
    else { $outHead = '[NonDebuggable] lines count as not run' }
    [void]$sb.AppendLine('<p class="headline">' + $outHead + '</p><dl class="facts">')
    [void]$sb.Append('<dt>[NonDebuggable] procedures</dt><dd>')
    if ($ndList.Count -eq 0) { [void]$sb.Append('None') }
    else {
        $state = if ($ndIncluded) { 'counted as not run' } else { 'not in the percentage' }
        $ndLines = 0
        foreach ($u in $ndList) { $ndLines += [int] $u.lines }
        [void]$sb.Append([string]::Format($ic, '<details class="list unmeasured"><summary>{0} {1}, {2} {3}, {4}</summary><div class="table-wrap"><table class="r-table"><thead><tr><th scope="col">Object</th><th scope="col">Procedure</th><th scope="col" class="n">Lines</th><th scope="col">Source</th></tr></thead><tbody>',
                $ndList.Count, (& $plural $ndList.Count 'procedure' 'procedures'), $ndLines, (& $plural $ndLines 'line' 'lines'), $state))
        foreach ($u in $ndList) {
            [void]$sb.Append([string]::Format($ic, '<tr><td>{0}</td><td class="mono">{1}</td><td class="n">{2}</td><td class="mono">{3}:{4}</td></tr>',
                    (& $esc (("{0} {1} {2}" -f $u.objectType, $u.objectId, $u.objectName).Trim())), (& $esc $u.procedure), $u.lines, (& $esc (& $relPathOf ([string] $u.filePath))), $u.line))
        }
        [void]$sb.Append('</tbody></table></div></details>')
    }
    [void]$sb.AppendLine('</dd>')
    [void]$sb.Append('<dt>albuild.json exclusions</dt><dd>')
    if ($exList.Count -eq 0) { [void]$sb.Append('None') }
    else {
        $exLines = 0
        foreach ($x in $exList) { $exLines += [int] $x.lineCount }
        [void]$sb.Append([string]::Format($ic, '<details class="list excluded"><summary>{0} {1}, {2} {3}, each with its reason</summary><div class="table-wrap"><table class="r-table"><thead><tr><th scope="col">Object</th><th scope="col">Procedure</th><th scope="col">Reason</th><th scope="col">Lines</th></tr></thead><tbody>',
                $exList.Count, (& $plural $exList.Count 'entry' 'entries'), $exLines, (& $plural $exLines 'line' 'lines')))
        foreach ($x in $exList) {
            $proc = if ($x.procedure) { $x.procedure } else { '(whole object)' }
            [void]$sb.Append([string]::Format($ic, '<tr><td>{0}</td><td class="mono">{1}</td><td>{2}</td><td>{3}</td></tr>',
                    (& $esc (("{0} {1} {2}" -f $x.objectType, $x.objectId, $x.objectName).Trim())), (& $esc $proc), (& $esc $x.reason),
                    (& $esc (ConvertTo-BcLineRangeText -Line @($x.lines))).Replace('-', '&#8211;')))
        }
        [void]$sb.Append('</tbody></table></div></details>')
    }
    [void]$sb.AppendLine('</dd>')
    $sourcedText = if ("$sourced" -ne '') { [string]::Format($ic, ' of {0} of {1} {2}', (& $num $sourced), (& $num $Summary.objectCount), (& $plural $Summary.objectCount 'object' 'objects')) } else { '' }
    $modeText = switch ($mode) {
        'Source' { 'Source: executable lines counted from the AL files' + $sourcedText }
        'Mixed' { 'Mixed: executable lines counted from the AL files' + $sourcedText + ', the rest from the coverage export only' }
        'CoveredOnly' { 'CoveredOnly: taken from the coverage export, which lists only lines that ran; not comparable with a source-derived figure' }
        default { & $esc $mode }
    }
    [void]$sb.AppendLine('<dt>Denominator</dt><dd>' + $modeText + '</dd></dl></section>')
    [void]$sb.AppendLine('</div>')

    # ---- objects: one list, each row the <summary> of its own <details> ----
    [void]$sb.AppendLine([string]::Format($ic, '<section class="objs" aria-label="Objects"><h2 class="sec">Objects <span class="count">{0}</span></h2>', (& $num $all.Count)))
    [void]$sb.AppendLine('<p class="lede">Lowest coverage first. Open an object for its procedures, the lines no test ran, and the annotated source.</p>')
    # Reductions are only known once everything has been rendered, but they belong above the data.
    [void]$sb.AppendLine('__NOTES__')

    # Status per source line: c ran, u not run, p partly run, n not measured, x excluded, none not executable.
    $buckets = if ($rendered.Count -gt 1000) { 24 } else { 48 }
    $anyPartial = $false
    $details = [System.Collections.Generic.List[object]]::new()
    $sourceMissing = 0
    foreach ($o in $rendered) {
        $status = @{}
        $lineRecords = @($o.lines | Where-Object { $null -ne $_ })
        # Checked once per object: the records of one object share their shape.
        $hasStatus = $lineRecords.Count -gt 0 -and (& $has $lineRecords[0] 'status')
        $hasPartial = $lineRecords.Count -gt 0 -and (& $has $lineRecords[0] 'partial')
        foreach ($l in $lineRecords) {
            $s = 'u'
            if ([int] $l.hits -gt 0) { $s = 'c' }
            if (($hasStatus -and $l.status -eq 'Partial') -or ($hasPartial -and [bool] $l.partial)) { $s = 'p'; $anyPartial = $true }
            $status[[int] $l.lineNo] = $s
        }
        # A continuation line shares the status of the line its statement starts on.
        foreach ($c in @(& $prop $o 'continuations')) { if ($c -and $status.ContainsKey([int] $c.statementLine)) { $status[[int] $c.lineNo] = $status[[int] $c.statementLine] } }
        foreach ($n in @(& $prop $o 'unmeasuredLines')) { if ($null -ne $n) { $status[[int] $n] = 'n' } }
        foreach ($n in @(& $prop $o 'excludedLines')) { if ($null -ne $n) { $status[[int] $n] = 'x' } }

        $srcFile = & $sourceFileOf $o
        $missing = $false
        if ($srcFile -and -not (Test-Path -LiteralPath $srcFile)) {
            # A missing file is a different problem from a full budget - usually a wrong workspace root -
            # and saying "omitted for size" about it would send the reader the wrong way.
            $sourceMissing++
            $missing = $true
            $srcFile = $null
        }
        $sourceText = @(if ($srcFile) { Get-Content -LiteralPath $srcFile -Encoding UTF8 -ErrorAction SilentlyContinue })

        # Gap map: the file from top to bottom, left to right, painted only where lines no test ran sit.
        # Bucketed so a single line stays visible; a fully covered file shows an empty track. One <i> per run
        # of buckets with an inline width - the tab drops custom properties and gradients, and adjacent
        # buckets are merged to keep the markup small. A trailing gap-free run needs no element.
        $lineCount = $sourceText.Count
        if ($lineCount -eq 0 -and $status.Count -gt 0) { $lineCount = [int] ($status.Keys | Measure-Object -Maximum).Maximum }
        $gapSegments = ''
        if ($lineCount -gt 0) {
            $marks = New-Object 'bool[]' $buckets
            $anyGap = $false
            for ($b = 0; $b -lt $buckets; $b++) {
                $from = [int] [math]::Floor($b * $lineCount / $buckets)
                $to = [int] [math]::Max($from + 1, [math]::Floor(($b + 1) * $lineCount / $buckets))
                for ($k = $from + 1; $k -le $to; $k++) { if ($status[$k] -eq 'u') { $marks[$b] = $true; $anyGap = $true; break } }
            }
            if ($anyGap) {
                $segs = [System.Text.StringBuilder]::new()
                $b = 0
                while ($b -lt $buckets) {
                    $e = $b
                    while ($e -lt $buckets -and $marks[$e] -eq $marks[$b]) { $e++ }
                    $w = [string]::Format($ic, '{0:0.##}', 100.0 * ($e - $b) / $buckets)
                    if ($marks[$b]) { [void]$segs.Append('<i class=u style="width:' + $w + '%"></i>') }
                    elseif ($e -lt $buckets) { [void]$segs.Append('<i style="width:' + $w + '%"></i>') }
                    $b = $e
                }
                $gapSegments = $segs.ToString()
            }
        }

        $lbl = & $labelOf $o
        $nm = if ($o.objectName) { & $esc $o.objectName } else { & $esc $lbl }
        $ty = if ($o.objectName) { ' <span class="ty">' + (& $esc $lbl) + '</span>' } else { '' }
        $chip = if (& $isNever $o) { ' <span class="chip bad">Never run</span>' } else { '' }
        $fileText = if ($o.filePath) { & $esc $o.filePath } else { 'no source file' }
        $total = [int] $o.totalExecutableLines
        $covered = [int] $o.coveredLines
        $notRun = $total - $covered
        $nr = if ($notRun -gt 0) { '<b>' + (& $num $notRun) + '</b> not run' } elseif ($total -gt 0) { 'all run' } else { 'nothing measured' }
        $head = [System.Text.StringBuilder]::new()
        # No id: the tab strips it, and nothing links to a row. The band stripe is a real element.
        [void]$head.Append([string]::Format($ic,
                '<details class="ob {0}"><summary><span class="sb"></span>{1}<span class="pc">{2}%</span><span class="o"><span class="nm">{3}</span>{4}{5}<span class="fp">{6}</span></span><span class="gm">{7}</span><span class="ln"><b>{8}</b> of {9}</span><span class="nr">{10}</span></summary><div class="det">',
                (& $band $o.lineCoverage), $chevron, (& $pct $o.lineCoverage), $nm, $ty, $chip, $fileText, $gapSegments, (& $num $covered), (& $num $total), $nr))
        [void]$head.Append([string]::Format($ic, '<p class="meta">{0} &middot; {1} of {2} executable {3} ran</p>', $fileText, (& $num $covered), (& $num $total), (& $plural $total 'line' 'lines')))
        if (& $isNever $o) {
            [void]$head.Append('<div class="callout bad">' + $iconWarn + '<p>Never run: no test loaded this object, so Business Central reported nothing for it. Every executable line is untested.</p></div>')
        }
        if ($missing) { [void]$head.Append('<p class="more">Annotated source not shown: the file was not found below the workspace root.</p>') }
        $details.Add([PSCustomObject]@{ Head = $head.ToString(); SourceText = $sourceText; Status = $status; Object = $o })
    }

    # The line-mark key: shape and weight tell the states apart, not only colour. 'Partly run' only when
    # the data has such lines.
    $keyPartial = if ($anyPartial) { '<span><i class="mk p"></i>partly run</span>' } else { '' }
    $key = '<p class="key"><span><i class="mk c"></i>ran</span><span><i class="mk u"></i>not run</span>' + $keyPartial +
    '<span><i class="mk n"></i>not measured or excluded</span><span>no mark: not executable</span>' +
    '<span><span class="gm"><i style="width:30%"></i><i class=u style="width:15%"></i><i style="width:35%"></i><i class=u style="width:8%"></i></span>gap map: where the lines no test ran sit in the file</span></p>'
    if ($rendered.Count -gt 0) {
        [void]$sb.AppendLine($key)
        [void]$sb.AppendLine('<div class="cols" aria-hidden="true"><span class="c-cv"></span><span class="c-pc" data-sort="pc">Covered</span><span class="c-o" data-sort="nm">Object</span><span class="c-gm">Gap map</span><span class="c-ln" data-sort="ln">Lines run</span><span class="c-nr" data-sort="nr">Not run</span></div>')
    }
    else { [void]$sb.AppendLine('<p class="empty">No object to list.</p>') }

    # The budget is a hard cap on the FINISHED file, in UTF-8 bytes. It used to be checked against the
    # characters written so far, before each object's source only - everything after the last check and
    # every multi-byte character came on top, and Banking's reports went out at 4.3-4.5 MB against a
    # "4096 KB" budget. Above roughly 4.3 MB the tab renders nothing at all, so the overshoot cost the
    # whole report, not just some source.
    #
    # Hence two passes. The first renders everything that is always shown and measures it; only what is
    # left of the budget after that goes to the per-procedure breakdown and annotated source, worst first.
    $utf8 = [System.Text.Encoding]::UTF8
    $foot = '</div></details>' + $nl
    $tail = '</section>' + $nl +
    '<p class="foot">ALbuild &middot; cobertura.xml and coverage-summary.json hold the same data in full when the run writes them.</p>' + $nl +
    '</main>' + $nl + '<script>' + $script + '</script>' + $nl + '</body>' + $nl + '</html>' + $nl
    # The notes replace '__NOTES__' once the reductions are known; a few callouts, reserved generously.
    $notesReserve = 3072
    $sourceOmitted = 0
    $fixedBytes = 3 + $utf8.GetByteCount($sb.ToString()) + $utf8.GetByteCount($tail) + $notesReserve
    foreach ($d in $details) { $fixedBytes += $utf8.GetByteCount($d.Head) + $utf8.GetByteCount($foot) }
    $sourceBudget = $MaxBytes - $fixedBytes

    $gapOmitted = 0
    foreach ($d in $details) {
        [void]$sb.Append($d.Head)
        $sourceText = $d.SourceText
        $never = & $isNever $d.Object

        # Per procedure: the ones with lines no test ran, and those lines with their source. Budgeted before
        # the annotated source, because it is the shorter and more useful answer to "what is not tested here".
        $gapProcs = @(& $prop $d.Object 'procedures' | Where-Object { $_ -and @($_.uncoveredLines).Count -gt 0 })
        if ($gapProcs.Count -gt 0) {
            $gapSb = [System.Text.StringBuilder]::new()
            $fullyCovered = @(& $prop $d.Object 'procedures' | Where-Object { $_ -and $_.measured -and @($_.uncoveredLines).Count -eq 0 }).Count
            [void]$gapSb.Append('<table class="procs"><thead><tr><th scope="col">Procedure</th><th scope="col">Lines run</th><th scope="col">Not run</th></tr></thead><tbody>')
            foreach ($p in $gapProcs) {
                $pv = 0.0
                if ([int] $p.executableLines -gt 0) { $pv = 100.0 * [int] $p.coveredLines / [int] $p.executableLines }
                [void]$gapSb.Append([string]::Format($ic, '<tr><th scope="row">{0}</th><td><span class="meter" aria-hidden="true"><i class="{1}" style="width:{2}%"></i></span> <b>{3}</b> of {4}</td><td class="rg">{5}</td></tr>',
                        (& $esc $p.name), (& $band $pv), (& $pct $pv), $p.coveredLines, $p.executableLines, (& $esc (ConvertTo-BcLineRangeText -Line @($p.uncoveredLines))).Replace('-', '&#8211;')))
            }
            [void]$gapSb.Append('</tbody></table>')
            if ($fullyCovered -gt 0) { [void]$gapSb.Append([string]::Format($ic, '<p class="more">{0} more {1} fully covered.</p>', $fullyCovered, (& $plural $fullyCovered 'procedure' 'procedures'))) }
            if ($sourceText.Count -gt 0 -and -not $never) {
                # One string: a newline between the spans would render as a blank line inside <pre>. The
                # line number is text in the <i>; the empty <i> of a procedure heading keeps it in the code column.
                [void]$gapSb.Append('<h3 class="eyebrow">Lines no test ran</h3><pre class="src gaps"><code>')
                foreach ($p in $gapProcs) {
                    [void]$gapSb.Append('<span class="ph"><i></i>' + (& $esc $p.name) + '</span>')
                    $nos = @(@($p.uncoveredLines) | Where-Object { [int] $_ -le $sourceText.Count })
                    # Dedent by the procedure's common indentation instead of trimming every line, so the
                    # statement under an 'if ... then' still reads as nested.
                    $indent = [int]::MaxValue
                    foreach ($n in $nos) {
                        $t = [string] $sourceText[[int] $n - 1]
                        if ($t.Trim()) { $indent = [math]::Min($indent, $t.Length - $t.TrimStart().Length) }
                    }
                    if ($indent -eq [int]::MaxValue) { $indent = 0 }
                    foreach ($n in $nos) {
                        $t = [string] $sourceText[[int] $n - 1]
                        $t = if ($t.Length -ge $indent) { $t.Substring($indent) } else { $t.TrimStart() }
                        [void]$gapSb.Append([string]::Format($ic, '<span class="u"><i>{0}</i>{1}</span>', $n, (& $esc $t)))
                    }
                }
                [void]$gapSb.Append('</code></pre>')
            }
            $gapHtml = $gapSb.ToString()
            $gapBytes = $utf8.GetByteCount($gapHtml)
            if ($gapBytes -le $sourceBudget) { [void]$sb.Append($gapHtml); $sourceBudget -= $gapBytes } else { $gapOmitted++ }
        }

        if ($sourceText.Count -gt 0) {
            $srcHtml = $null
            if ($sourceBudget -gt 0) {
                # The line number is real text in an <i> whose right border is the gutter mark: the tab
                # drops CSS counters and generated content. A line costs its text plus ~25 bytes.
                $status = $d.Status
                $srcSb = [System.Text.StringBuilder]::new()
                [void]$srcSb.Append([string]::Format($ic, '<details class="srcd"><summary>{0}<span class="show">Show annotated source</span><span class="hide">Hide annotated source</span> <span class="count">{1} {2}</span></summary><pre class="src full"><code>',
                        $chevron, (& $num $sourceText.Count), (& $plural $sourceText.Count 'line' 'lines')))
                $no = 0
                foreach ($text in $sourceText) {
                    $no++
                    $cls = $status[$no]
                    if ($cls) { [void]$srcSb.Append('<span class=' + $cls + '><i>') } else { [void]$srcSb.Append('<span><i>') }
                    [void]$srcSb.Append($no.ToString($ic)).Append('</i>').Append((& $esc $text)).Append('</span>')
                }
                [void]$srcSb.Append('</code></pre></details>')
                $srcHtml = $srcSb.ToString()
                $srcBytes = $utf8.GetByteCount($srcHtml)
                if ($srcBytes -le $sourceBudget) { $sourceBudget -= $srcBytes }
                else { $srcHtml = $null }
            }
            if ($srcHtml) { [void]$sb.Append($srcHtml) } else { $sourceOmitted++ }
        }
        [void]$sb.Append($foot)
    }

    [void]$sb.Append($tail)

    $notes = [System.Collections.Generic.List[string]]::new()
    if ($truncated) {
        $notes.Add([string]::Format($ic,
                'Showing the {0} lowest-covered of {1} objects. The Azure DevOps coverage tab stops rendering large reports, so the list was capped.',
                $rendered.Count, $all.Count))
    }
    if ($sourceOmitted -gt 0) {
        $notes.Add('Annotated source omitted for ' + $sourceOmitted + ' object(s) to keep the report within the size the coverage tab can render.')
    }
    if ($gapOmitted -gt 0) {
        $notes.Add('The per-procedure breakdown is omitted for ' + $gapOmitted +
            ' object(s) to keep the report within the size the coverage tab can render; coverage-summary.json has it in full.')
    }
    if ($sourceMissing -gt 0) {
        $notes.Add('' + $sourceMissing + ' object(s) have a source file that was not found below the workspace root, so no annotated source is shown for them.')
    }
    $notesHtml = (@($notes | ForEach-Object { '<div class="callout warn">' + $iconWarn + '<p>' + $_ + '</p></div>' }) -join $nl)
    $html = $sb.ToString().Replace('__NOTES__', $notesHtml)

    if ($truncated -or $sourceOmitted -gt 0 -or $sourceMissing -gt 0 -or $gapOmitted -gt 0) {
        $why = @()
        if ($truncated) { $why += "$($rendered.Count) of $($all.Count) object(s) listed" }
        if ($gapOmitted -gt 0) { $why += "per-procedure breakdown dropped for $gapOmitted object(s)" }
        if ($sourceOmitted -gt 0) { $why += "annotated source dropped for $sourceOmitted object(s) to stay inside the $MaxBytes-byte budget" }
        if ($sourceMissing -gt 0) { $why += "$sourceMissing object(s) had no source file below the workspace root" }
        Write-ALbuildLog -Level Warning ('Coverage HTML report is incomplete: ' + ($why -join '; ') +
            '. 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)))

    # Only what is always shown can still push the file over the cap (thousands of objects, or a very
    # long test-quality list). The tab then shows nothing, and that must not be a surprise.
    $written = (Get-Item -LiteralPath $Path).Length
    if ($written -gt $MaxBytes) {
        Write-ALbuildLog -Level Warning ([string]::Format($ic,
                'Coverage HTML report is {0} bytes, above the {1}-byte budget even without annotated source. The Azure DevOps coverage tab may show nothing; the report artifact can still be downloaded. Lower MaxObjects to fit.',
                $written, $MaxBytes))
    }

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


function Write-CoverageDeltaMarkdown {
    <#
    .SYNOPSIS
        Writes the patch-coverage summary for a pull request: the figure, the gate, and every uncovered changed
        line with its procedure and source.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Summary,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Files,
        [string] $SourceRoot,
        [Parameter(Mandatory)] [string] $Path,
        [int] $MaxLinesPerFile = 50
    )
    $ic = [System.Globalization.CultureInfo]::InvariantCulture
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('# ALbuild Patch Coverage').AppendLine()
    $base = if ($Summary.mergeBase) { "$($Summary.baselineRef) (merge base $("$($Summary.mergeBase)".Substring(0, [Math]::Min(10, "$($Summary.mergeBase)".Length))))" } else { "$($Summary.baselineRef)" }
    if (-not $Summary.measurable) {
        [void]$sb.AppendLine("No changed executable line against $base - nothing to judge.").AppendLine()
    }
    else {
        [void]$sb.AppendLine([string]::Format($ic, '**Patch coverage: {0}% ({1} / {2} changed lines)** against {3}.', $Summary.deltaCoverage, $Summary.coveredChangedLines, $Summary.changedExecutableLines, $base)).AppendLine()
    }
    if ($null -ne $Summary.minDeltaCoverage) {
        $verdict = if ($Summary.passed) { 'passed' } else { 'FAILED' }
        [void]$sb.AppendLine([string]::Format($ic, 'Gate: {0} (minimum {1}%).', $verdict, $Summary.minDeltaCoverage)).AppendLine()
    }
    if ([int]$Summary.unmeasuredChangedLines -gt 0) { [void]$sb.AppendLine([string]::Format($ic, '{0} changed line(s) in [NonDebuggable] code cannot be measured and are not in the percentage.', $Summary.unmeasuredChangedLines)).AppendLine() }
    if ([int]$Summary.excludedChangedLines -gt 0) { [void]$sb.AppendLine([string]::Format($ic, '{0} changed line(s) are excluded by albuild.json and are not in the percentage.', $Summary.excludedChangedLines)).AppendLine() }

    $withGaps = @($Files | Where-Object { @($_.uncoveredLines).Count -gt 0 })
    if ($withGaps.Count -gt 0) {
        [void]$sb.AppendLine('## Uncovered changed lines').AppendLine()
        foreach ($f in $withGaps) {
            [void]$sb.AppendLine([string]::Format($ic, '### {0} - {1}% ({2} / {3})', $f.filePath, $f.deltaCoverage, $f.coveredChangedLines, $f.changedExecutableLines)).AppendLine()
            $text = @()
            if ($SourceRoot) {
                $file = Join-Path $SourceRoot ($f.filePath -replace '/', [System.IO.Path]::DirectorySeparatorChar)
                if (Test-Path -LiteralPath $file) { $text = @(Get-Content -LiteralPath $file) }
            }
            [void]$sb.AppendLine('```al')
            foreach ($u in @($f.uncovered | Select-Object -First $MaxLinesPerFile)) {
                $src = if ($u.line -le $text.Count) { $text[$u.line - 1].Trim() } else { '' }
                $proc = if ($u.procedure) { "[$($u.procedure)] " } else { '' }
                [void]$sb.AppendLine([string]::Format($ic, '{0,5}: {1}{2}', $u.line, $proc, $src))
            }
            if (@($f.uncovered).Count -gt $MaxLinesPerFile) { [void]$sb.AppendLine([string]::Format($ic, ' ... {0} more', @($f.uncovered).Count - $MaxLinesPerFile)) }
            [void]$sb.AppendLine('```').AppendLine()
        }
    }
    $covered = @($Files | Where-Object { @($_.uncoveredLines).Count -eq 0 -and [int]$_.changedExecutableLines -gt 0 })
    if ($covered.Count -gt 0) {
        [void]$sb.AppendLine('## Fully covered changes').AppendLine()
        foreach ($f in $covered) { [void]$sb.AppendLine([string]::Format($ic, '- {0} ({1} line(s))', $f.filePath, $f.changedExecutableLines)) }
        [void]$sb.AppendLine()
    }
    Set-Content -LiteralPath $Path -Value $sb.ToString() -Encoding UTF8
}