← Files NightshiftARCHIVED FILE

runtime/windows/normalize-output.ps1

38 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

[CmdletBinding(PositionalBinding = $false)]
param(
    [string]$Format = '',
    [string]$InputPath = '',
    [int]$Top = 10,
    [switch]$Json
)

# normalize-output.ps1 - the native Windows twin of runtime/normalize-output.sh.
#
#   normalize-output.ps1 -Format <fmt> -InputPath <file> [-Top N] [-Json]
#
# Same formats, same summary, same bytes: the parity test diffs both engines over
# every fixture. PowerShell reads JSON itself, so this side never needs jq.
#
# Two digests travel with a summary. `digest` covers the result - the format, the
# headline and the counts - so a rerun that reports the same numbers keeps one
# digest. `source` covers the raw file byte for byte.
#
# Exit: 0 summary - 1 usage - 3 unavailable

Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
if (Test-Path Variable:PSNativeCommandUseErrorActionPreference) {
    $PSNativeCommandUseErrorActionPreference = $false
}

$pluginRoot = Resolve-Path (Join-Path $PSScriptRoot '../..')
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking

function Write-NOUsage {
    param([string]$Message)
    [Console]::Error.WriteLine("normalize-output: $Message")
    exit 1
}

if ([string]::IsNullOrWhiteSpace($Format)) {
    Write-NOUsage 'usage: normalize-output.ps1 -Format <fmt> -InputPath <file> [-Top N] [-Json]'
}
if ([string]::IsNullOrWhiteSpace($InputPath)) {
    Write-NOUsage 'usage: normalize-output.ps1 -Format <fmt> -InputPath <file> [-Top N] [-Json]'
}
if ($Top -lt 0) {
    Write-NOUsage '-Top takes a whole number'
}

if ($Format -ceq 'pytest-junit') { $Format = 'junit' }
$known = @('eslint-json', 'tsc', 'coverage-summary', 'sarif', 'npm-audit', 'junit', 'lcov')
if ($known -cnotcontains $Format) {
    Write-NOUsage "unknown format: $Format"
}

$script:Out = New-Object Text.StringBuilder

function Write-NOLine {
    param([string]$Text = '')
    [void]$script:Out.Append($Text)
    [void]$script:Out.Append("`n")
}

function Complete-NOOutput {
    [Console]::Out.Write($script:Out.ToString())
    [Console]::Out.Flush()
}

# The one line a caller reads when nothing was parsed. Never a zero-finding
# summary: a tool that did not report is not a tool that found nothing.
function Write-NOUnavailable {
    param([string]$Reason)
    [Console]::Out.Write("unavailable $Format" + ': ' + $Reason + "`n")
    [Console]::Out.Flush()
    exit 3
}

# --------------------------------------------------------------- shared shaping

function Get-NOSanitized {
    param([AllowEmptyString()][AllowNull()][string]$Text)
    if ($null -eq $Text) { $Text = '' }
    $sb = New-Object Text.StringBuilder
    foreach ($ch in $Text.ToCharArray()) {
        $code = [int]$ch
        if ($code -ge 32 -and $code -le 126) { [void]$sb.Append($ch) }
        else { [void]$sb.Append(' ') }
    }
    $value = $sb.ToString()
    while ($value.Contains('  ')) { $value = $value.Replace('  ', ' ') }
    $value = $value.Trim([char]' ')
    if ($value.Length -gt 100) { $value = $value.Substring(0, 97) + '...' }
    if ($value -eq '') { $value = '-' }
    return $value
}

# Control characters out, everything else kept. This is the form a raw file path or
# uri is counted in, so two paths that differ only outside ASCII stay two paths.
function Get-NOControlScrubbed {
    param([AllowEmptyString()][AllowNull()][string]$Text)
    if ($null -eq $Text) { return '' }
    $sb = New-Object Text.StringBuilder
    foreach ($ch in $Text.ToCharArray()) {
        $code = [int]$ch
        if ($code -lt 32 -or $code -eq 127) { [void]$sb.Append(' ') }
        else { [void]$sb.Append($ch) }
    }
    return $sb.ToString()
}

function Get-NOPathText {
    param([string]$Text)
    $sb = New-Object Text.StringBuilder
    foreach ($ch in $Text.ToCharArray()) {
        $code = [int]$ch
        if ($code -ge 32 -and $code -le 126) { [void]$sb.Append($ch) }
        else { [void]$sb.Append(' ') }
    }
    $value = $sb.ToString()
    while ($value.Contains('  ')) { $value = $value.Replace('  ', ' ') }
    return $value.Trim([char]' ')
}

function Get-NORank {
    param([string]$Severity)
    switch -CaseSensitive ($Severity) {
        'critical' { return 5 }
        'error' { return 4 }
        'high' { return 4 }
        'warning' { return 3 }
        'moderate' { return 3 }
        'note' { return 2 }
        'low' { return 2 }
        'info' { return 1 }
        default { return 0 }
    }
}

function Get-NOJsonEscaped {
    param([string]$Text)
    $sb = New-Object Text.StringBuilder
    foreach ($ch in $Text.ToCharArray()) {
        if ($ch -eq '"') { [void]$sb.Append('\"') }
        elseif ($ch -eq '\') { [void]$sb.Append('\\') }
        else { [void]$sb.Append($ch) }
    }
    return $sb.ToString()
}

function Get-NOCellEscaped {
    param([string]$Text)
    return $Text.Replace('|', '\|')
}

# Basis points, integer arithmetic only, so both engines round the same way. A zero
# denominator is not full coverage; it is no measurement, and -1 is how every reader
# of these three tells one from the other.
function Get-NOBasisPoints {
    param([long]$Covered, [long]$Total)
    if ($Total -le 0) { return [long](-1) }
    return [long][Math]::Floor(($Covered * 20000 + $Total) / (2 * $Total))
}

function Get-NOPercentLabel {
    param([long]$Covered, [long]$Total)
    $bp = Get-NOBasisPoints $Covered $Total
    if ($bp -lt 0) { return 'unmeasured' }
    return ('{0}.{1:D2}%' -f [long][Math]::Floor($bp / 100), ($bp % 100))
}

function Get-NOBand {
    param([long]$Covered, [long]$Total)
    $bp = Get-NOBasisPoints $Covered $Total
    if ($bp -lt 0) { return 'info' }
    if ($bp -lt 5000) { return 'error' }
    if ($bp -lt 8000) { return 'warning' }
    return 'note'
}

function Get-NOPlural {
    param([long]$Count, [string]$Word)
    if ($Count -eq 1) { return "$Count $Word" }
    return "$Count ${Word}s"
}

# --------------------------------------------------------------- JSON accessors

function Get-NOField {
    param($Map, [string]$Key)
    if ($null -eq $Map) { return $null }
    if (-not ($Map -is [Collections.IDictionary])) { return $null }
    if (-not $Map.Contains($Key)) { return $null }
    return $Map[$Key]
}

# A list value needs the comma: a bare return unrolls it, and a one-element list
# would then arrive as a scalar. Callers assign the result and never re-wrap it.
function Get-NOArray {
    param($Map, [string]$Key)
    $value = Get-NOField $Map $Key
    if ($null -eq $value) { return , ([object[]]@()) }
    if ($value -is [Array]) { return , ([object[]]$value) }
    return , ([object[]]@($value))
}

function Test-NOArrayValue {
    param($Map, [string]$Key)
    if ($null -eq $Map -or -not ($Map -is [Collections.IDictionary])) { return $false }
    if (-not $Map.Contains($Key)) { return $false }
    return ($Map[$Key] -is [Array])
}

function Get-NONumber {
    param($Map, [string]$Key)
    $value = Get-NOField $Map $Key
    if ($null -eq $value -or $value -is [bool]) { return [long]0 }
    try { return [long][Math]::Floor([double]$value) }
    catch { return [long]0 }
}

function Get-NOText {
    param($Value)
    if ($null -eq $Value) { return '-' }
    if ($Value -is [bool]) { if ($Value) { return 'true' } return 'false' }
    return [string]$Value
}

function Get-NOKeys {
    param($Map)
    if ($null -eq $Map -or -not ($Map -is [Collections.IDictionary])) { return @() }
    $keys = [string[]]@($Map.Keys)
    [Array]::Sort($keys, [StringComparer]::Ordinal)
    return $keys
}

# --------------------------------------------------------------- the input file

if ([IO.Path]::IsPathRooted($InputPath)) { $absolute = $InputPath }
else { $absolute = Join-Path ([string](Get-Location).ProviderPath) $InputPath }

if (-not (Test-Path -LiteralPath $absolute -PathType Leaf)) {
    Write-NOUnavailable 'the input is not a readable file'
}

try { $sourceDigest = Get-NSFileSha256 $absolute }
catch { Write-NOUnavailable 'no sha256 tool on this host, so the summary cannot be anchored' }

$script:Headline = '-'
$script:Counts = New-Object 'Collections.Generic.List[object]'
$script:Files = 0
$script:Items = New-Object 'Collections.Generic.List[object]'

function Add-NOCount {
    param([string]$Label, [long]$Value)
    [void]$script:Counts.Add([pscustomobject]@{ Label = $Label; Value = $Value })
}

function Add-NOItem {
    param(
        [string]$Severity,
        [AllowEmptyString()][string]$File,
        [long]$Line,
        [AllowEmptyString()][string]$Code,
        [AllowEmptyString()][string]$Detail
    )
    [void]$script:Items.Add([pscustomobject]@{
            Severity = $Severity; File = $File; Line = $Line; Code = $Code; Detail = $Detail
        })
}

function Read-NOJsonRoot {
    $text = [IO.File]::ReadAllText($absolute, (New-Object Text.UTF8Encoding($false)))
    $trimmed = $text.TrimStart(" `t`r`n")
    $kind = 'other'
    if ($trimmed.StartsWith('[', [StringComparison]::Ordinal)) { $kind = 'array' }
    elseif ($trimmed.StartsWith('{', [StringComparison]::Ordinal)) { $kind = 'object' }
    $parsed = $null
    try { $parsed = ConvertFrom-NSJsonText $text }
    catch { Write-NOUnavailable 'the input is not readable JSON' }
    return [pscustomobject]@{ Kind = $kind; Data = $parsed }
}

function Read-NOLines {
    $bytes = [IO.File]::ReadAllBytes($absolute)
    $latin = [Text.Encoding]::GetEncoding(28591)
    $text = $latin.GetString($bytes)
    return $text.Split("`n")
}

# --------------------------------------------------------------- eslint-json

# eslint writes severity as a number, and a few formatters write the same number as
# a string. Both mean the same level, so both read as that number.
function Get-NOSeverityNumber {
    param($Value)
    if ($null -eq $Value -or $Value -is [bool]) { return [double](-1) }
    if ($Value -is [string]) {
        $styles = [Globalization.NumberStyles]::AllowLeadingSign -bor
        [Globalization.NumberStyles]::AllowDecimalPoint -bor
        [Globalization.NumberStyles]::AllowExponent
        $parsed = [double]0
        if ([double]::TryParse($Value, $styles,
                [Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) { return $parsed }
        return [double](-1)
    }
    try { return [double]$Value }
    catch { return [double](-1) }
}

function Read-NOEslint {
    $root = Read-NOJsonRoot
    if ($root.Kind -ne 'array') { Write-NOUnavailable 'the report is not a JSON array' }
    $rows = @()
    if ($null -ne $root.Data) { $rows = @($root.Data) }
    foreach ($row in $rows) {
        if (-not ($row -is [Collections.IDictionary]) -or -not $row.Contains('messages')) {
            Write-NOUnavailable 'the report is not eslint file results'
        }
        if (-not (Test-NOArrayValue $row 'messages')) {
            Write-NOUnavailable 'the report is not eslint file results'
        }
    }
    $errors = 0
    $warnings = 0
    $files = New-Object 'Collections.Generic.List[string]'
    foreach ($row in $rows) {
        $path = Get-NOText (Get-NOField $row 'filePath')
        $messages = Get-NOArray $row 'messages'
        $counted = Get-NOControlScrubbed $path
        if ($messages.Count -gt 0 -and -not $files.Contains($counted)) { [void]$files.Add($counted) }
        foreach ($message in $messages) {
            $severity = Get-NOSeverityNumber (Get-NOField $message 'severity')
            $label = 'note'
            if ($severity -eq 2) { $label = 'error'; $errors++ }
            elseif ($severity -eq 1) { $label = 'warning'; $warnings++ }
            Add-NOItem $label $path (Get-NONumber $message 'line') `
            (Get-NOText (Get-NOField $message 'ruleId')) (Get-NOText (Get-NOField $message 'message'))
        }
    }
    $script:Files = $files.Count
    $script:Headline = 'eslint: ' + (Get-NOPlural $errors 'error') + ', ' +
        (Get-NOPlural $warnings 'warning') + ' in ' + (Get-NOPlural $files.Count 'file')
    Add-NOCount 'errors' $errors
    Add-NOCount 'warnings' $warnings
}

# --------------------------------------------------------------- coverage-summary

function Read-NOCoverage {
    $root = Read-NOJsonRoot
    if ($root.Kind -ne 'object') { Write-NOUnavailable 'the report is not a JSON object' }
    $data = $root.Data
    $total = Get-NOField $data 'total'
    if ($null -eq $total -or -not ($total -is [Collections.IDictionary]) -or -not $total.Contains('lines')) {
        Write-NOUnavailable 'the report has no total.lines block'
    }
    $metrics = @{}
    foreach ($name in @('lines', 'statements', 'functions', 'branches')) {
        $block = Get-NOField $total $name
        $metrics[$name] = @((Get-NONumber $block 'covered'), (Get-NONumber $block 'total'))
    }
    $files = @(Get-NOKeys $data | Where-Object { $_ -ne 'total' })
    $script:Files = $files.Count
    $script:Headline = 'coverage: lines ' + (Get-NOPercentLabel $metrics['lines'][0] $metrics['lines'][1]) +
        ', statements ' + (Get-NOPercentLabel $metrics['statements'][0] $metrics['statements'][1]) +
        ', functions ' + (Get-NOPercentLabel $metrics['functions'][0] $metrics['functions'][1]) +
        ', branches ' + (Get-NOPercentLabel $metrics['branches'][0] $metrics['branches'][1]) +
        ' across ' + (Get-NOPlural $files.Count 'file')
    Add-NOCount 'branchesCovered' $metrics['branches'][0]
    Add-NOCount 'branchesTotal' $metrics['branches'][1]
    Add-NOCount 'functionsCovered' $metrics['functions'][0]
    Add-NOCount 'functionsTotal' $metrics['functions'][1]
    Add-NOCount 'linesCovered' $metrics['lines'][0]
    Add-NOCount 'linesTotal' $metrics['lines'][1]
    Add-NOCount 'statementsCovered' $metrics['statements'][0]
    Add-NOCount 'statementsTotal' $metrics['statements'][1]
    foreach ($name in $files) {
        $lines = Get-NOField (Get-NOField $data $name) 'lines'
        $covered = Get-NONumber $lines 'covered'
        $count = Get-NONumber $lines 'total'
        Add-NOItem (Get-NOBand $covered $count) $name 0 'lines' `
        ("$covered/$count lines covered (" + (Get-NOPercentLabel $covered $count) + ')')
    }
}

# --------------------------------------------------------------- sarif

function Get-NOSarifLevel {
    param($Value)
    if ($null -eq $Value) { return 'warning' }
    $level = [string]$Value
    if ($level -ceq 'error' -or $level -ceq 'warning' -or $level -ceq 'note') { return $level }
    return 'note'
}

function Read-NOSarif {
    $root = Read-NOJsonRoot
    if ($root.Kind -ne 'object') { Write-NOUnavailable 'the report is not a JSON object' }
    $data = $root.Data
    if (-not (Test-NOArrayValue $data 'runs')) {
        Write-NOUnavailable 'the report has no runs array'
    }
    $runs = Get-NOArray $data 'runs'
    if ($data.Contains('version') -and
        -not ([string](Get-NOField $data 'version')).StartsWith('2.1', [StringComparison]::Ordinal)) {
        Write-NOUnavailable 'the report is not SARIF 2.1'
    }
    $counts = @{ 'error' = [long]0; 'warning' = [long]0; 'note' = [long]0 }
    $files = New-Object 'Collections.Generic.List[string]'
    foreach ($run in $runs) {
        $results = Get-NOArray $run 'results'
        foreach ($result in $results) {
            if ($null -eq $result) { continue }
            $level = Get-NOSarifLevel (Get-NOField $result 'level')
            $counts[$level] = $counts[$level] + 1
            $uri = '-'
            $line = [long]0
            $locations = Get-NOArray $result 'locations'
            if ($locations.Count -gt 0 -and $null -ne $locations[0]) {
                $physical = Get-NOField $locations[0] 'physicalLocation'
                $artifact = Get-NOField $physical 'artifactLocation'
                $found = Get-NOField $artifact 'uri'
                if ($null -ne $found) { $uri = [string]$found }
                $line = Get-NONumber (Get-NOField $physical 'region') 'startLine'
            }
            # The count is of files, so it uniques the uri the report gave. The display
            # form folds every byte outside ASCII into a space, which would make two
            # paths that differ only by an accent read as one file.
            $counted = Get-NOControlScrubbed $uri
            if (-not $files.Contains($counted)) { [void]$files.Add($counted) }
            $message = Get-NOField $result 'message'
            $text = Get-NOField $message 'text'
            if ($null -eq $text) { $text = Get-NOField $message 'markdown' }
            Add-NOItem $level $uri $line (Get-NOText (Get-NOField $result 'ruleId')) (Get-NOText $text)
        }
    }
    $script:Files = $files.Count
    $script:Headline = 'sarif: ' + (Get-NOPlural $counts['error'] 'error') + ', ' +
        (Get-NOPlural $counts['warning'] 'warning') + ', ' + (Get-NOPlural $counts['note'] 'note') +
        ' in ' + (Get-NOPlural $files.Count 'file')
    Add-NOCount 'errors' $counts['error']
    Add-NOCount 'notes' $counts['note']
    Add-NOCount 'warnings' $counts['warning']
}

# --------------------------------------------------------------- npm-audit

function Get-NOAuditSeverity {
    param($Value)
    if ($null -eq $Value) { return 'info' }
    $level = [string]$Value
    if (@('critical', 'high', 'moderate', 'low') -ccontains $level) { return $level }
    return 'info'
}

function Read-NOAudit {
    $root = Read-NOJsonRoot
    if ($root.Kind -ne 'object') { Write-NOUnavailable 'the report is not a JSON object' }
    $data = $root.Data
    if ($data.Contains('advisories') -and -not $data.Contains('auditReportVersion')) {
        Write-NOUnavailable 'the report predates npm audit version 7'
    }
    $vulnerabilities = Get-NOField $data 'vulnerabilities'
    if (-not $data.Contains('auditReportVersion') -or
        $null -eq $vulnerabilities -or -not ($vulnerabilities -is [Collections.IDictionary])) {
        Write-NOUnavailable 'the report has no npm audit vulnerabilities object'
    }
    $names = @(Get-NOKeys $vulnerabilities)
    $counts = @{ 'critical' = [long]0; 'high' = [long]0; 'moderate' = [long]0; 'low' = [long]0; 'info' = [long]0 }
    foreach ($name in $names) {
        $entry = Get-NOField $vulnerabilities $name
        $level = Get-NOAuditSeverity (Get-NOField $entry 'severity')
        $counts[$level] = $counts[$level] + 1
        $title = '-'
        $via = Get-NOArray $entry 'via'
        if ($via.Count -gt 0 -and $null -ne $via[0]) {
            if ($via[0] -is [Collections.IDictionary]) { $title = Get-NOText (Get-NOField $via[0] 'title') }
            else { $title = [string]$via[0] }
        }
        $fix = 'none'
        $available = Get-NOField $entry 'fixAvailable'
        if ($null -ne $available -and -not ($available -is [bool] -and -not [bool]$available)) {
            $fix = 'available'
        }
        Add-NOItem $level $name 0 (Get-NOText (Get-NOField $entry 'range')) "$title; fix: $fix"
    }
    $script:Files = $names.Count
    $script:Headline = 'npm-audit: ' + (Get-NOPlural $names.Count 'vulnerable package') + ': ' +
    "$($counts['critical']) critical, $($counts['high']) high, $($counts['moderate']) moderate, " +
    "$($counts['low']) low, $($counts['info']) info"
    Add-NOCount 'critical' $counts['critical']
    Add-NOCount 'high' $counts['high']
    Add-NOCount 'info' $counts['info']
    Add-NOCount 'low' $counts['low']
    Add-NOCount 'moderate' $counts['moderate']
    Add-NOCount 'total' $names.Count
}

# --------------------------------------------------------------- tsc

function Read-NOTsc {
    $errors = [long]0
    $warnings = [long]0
    $noise = 0
    $summaries = 0
    $counted = [long]0
    $seen = New-Object 'Collections.Generic.List[string]'
    foreach ($raw in Read-NOLines) {
        $line = $raw
        if ($line.EndsWith("`r", [StringComparison]::Ordinal)) { $line = $line.Substring(0, $line.Length - 1) }
        # --pretty colours its diagnostics, and a report captured to a file keeps the
        # escapes. They are decoration: strip them before anything is matched.
        $line = [Text.RegularExpressions.Regex]::Replace($line, "$([char]27)\[[0-9;]*[A-Za-z]", '')
        if ($line -cmatch '^[ \t]*$') { continue }
        $p = $line.IndexOf('): ')
        $head = ''
        $rest = ''
        if ($p -ge 0) {
            $head = $line.Substring(0, $p + 1)
            $rest = $line.Substring($p + 3)
        }
        if ($p -ge 0 -and $head -cmatch '\([0-9]+,[0-9]+\)$' -and $rest -cmatch '^(error|warning) TS[0-9]+: ') {
            $q = $head.LastIndexOf('(')
            $file = $head.Substring(0, $q)
            $lc = $head.Substring($q + 1, $head.Length - $q - 2)
            $number = [long]0
            $parts = $lc.Split(',')
            if ($parts.Count -gt 0) { $number = [long]$parts[0] }
            $emitted = Split-NOTscDiagnostic $rest $file $number
            if ($emitted -eq 'error') { $errors++ } else { $warnings++ }
            if (-not $seen.Contains($file)) { [void]$seen.Add($file) }
            continue
        }
        # file:line:col - error TS1234: message, which is what --pretty writes. A drive
        # letter puts colons in the path too, so the line and column are taken from the
        # end and everything before them is the file. The header starts a line; the same
        # shape indented is the tail of a diagnostic whose head this input never carried.
        $d = $line.IndexOf(' - ')
        if ($d -ge 0 -and -not ($line -cmatch '^[ \t]')) {
            $head = $line.Substring(0, $d)
            $rest = $line.Substring($d + 3)
            if ($head -cmatch ':[0-9]+:[0-9]+$' -and $rest -cmatch '^(error|warning) TS[0-9]+: ') {
                $parts = $head.Split(':')
                $file = [string]::Join(':', $parts[0..($parts.Count - 3)])
                $number = [long]$parts[$parts.Count - 2]
                $emitted = Split-NOTscDiagnostic $rest $file $number
                if ($emitted -eq 'error') { $errors++ } else { $warnings++ }
                if (-not $seen.Contains($file)) { [void]$seen.Add($file) }
                continue
            }
        }
        if ($line -cmatch '^(error|warning) TS[0-9]+: ') {
            $emitted = Split-NOTscDiagnostic $line '-' 0
            if ($emitted -eq 'error') { $errors++ } else { $warnings++ }
            continue
        }
        if ($line -cmatch '^Found [0-9]+ error') {
            $summaries++
            $counted = [long]($line.Split(' ')[1])
            continue
        }
        # Every other non-blank line counts, indented continuations included: an input
        # of nothing but continuation lines is a report this parser did not read, not a
        # clean compile.
        $noise++
    }
    # A watch log is several reports in one file, and none of them describes the
    # whole input. Read it as unavailable rather than as the last one that ran.
    if ($summaries -gt 1) {
        Write-NOUnavailable 'the input holds more than one TypeScript report'
    }
    if ($script:Items.Count -eq 0 -and $summaries -eq 0 -and $noise -gt 0) {
        Write-NOUnavailable 'the input holds no TypeScript diagnostics'
    }
    # A report that counts its own errors is the authority on how many there were.
    # Reading fewer than it counted means diagnostics in a shape this parser does
    # not know, so the answer is unavailable - never the total rounded down to what
    # happened to parse, and never rows invented to reach the total.
    if ($summaries -eq 1 -and $counted -ne $errors) {
        Write-NOUnavailable ('the report counts ' + (Get-NOPlural $counted 'error') + ' and this parser read ' + $errors)
    }
    $script:Files = $seen.Count
    $script:Headline = 'tsc: ' + (Get-NOPlural $errors 'error') + ', ' +
        (Get-NOPlural $warnings 'warning') + ' in ' + (Get-NOPlural $seen.Count 'file')
    Add-NOCount 'errors' $errors
    Add-NOCount 'warnings' $warnings
}

function Split-NOTscDiagnostic {
    param([string]$Rest, [string]$File, [long]$Line)
    $s1 = $Rest.IndexOf(' ')
    $severity = $Rest.Substring(0, $s1)
    $tail = $Rest.Substring($s1 + 1)
    $s2 = $tail.IndexOf(' ')
    $code = $tail.Substring(0, $s2 - 1)
    $detail = $tail.Substring($s2 + 1)
    Add-NOItem $severity $File $Line $code $detail
    return $severity
}

# --------------------------------------------------------------- lcov

function Read-NOLcov {
    $sf = ''
    $lf = [long]0
    $lh = [long]0
    $have = $false
    $files = 0
    $totalLf = [long]0
    $totalLh = [long]0
    foreach ($raw in Read-NOLines) {
        $line = $raw
        if ($line.EndsWith("`r", [StringComparison]::Ordinal)) { $line = $line.Substring(0, $line.Length - 1) }
        if ($line -cmatch '^[ \t]*$') { continue }
        if ($line.StartsWith('SF:', [StringComparison]::Ordinal)) {
            $sf = $line.Substring(3); $lf = 0; $lh = 0; $have = $true; continue
        }
        if ($line.StartsWith('LF:', [StringComparison]::Ordinal)) {
            $lf = [long]($line.Substring(3) -as [double]); continue
        }
        if ($line.StartsWith('LH:', [StringComparison]::Ordinal)) {
            $lh = [long]($line.Substring(3) -as [double]); continue
        }
        if ($line -ceq 'end_of_record') {
            if ($have) {
                $files++
                $totalLf += $lf
                $totalLh += $lh
                Add-NOItem (Get-NOBand $lh $lf) $sf 0 'lines' `
                ("$lh/$lf lines covered (" + (Get-NOPercentLabel $lh $lf) + ')')
                $have = $false
            }
            continue
        }
    }
    if ($have) {
        $files++
        $totalLf += $lf
        $totalLh += $lh
        Add-NOItem (Get-NOBand $lh $lf) $sf 0 'lines' `
        ("$lh/$lf lines covered (" + (Get-NOPercentLabel $lh $lf) + ')')
    }
    if ($files -eq 0) { Write-NOUnavailable 'the input holds no lcov SF records' }
    $script:Files = $files
    $script:Headline = 'lcov: ' + (Get-NOPercentLabel $totalLh $totalLf) + ' lines covered, ' +
    "$totalLh/$totalLf in " + (Get-NOPlural $files 'file')
    Add-NOCount 'linesCovered' $totalLh
    Add-NOCount 'linesTotal' $totalLf
}

# --------------------------------------------------------------- junit

function Get-NOUnentity {
    param([string]$Text)
    $value = $Text.Replace('&lt;', '<')
    $value = $value.Replace('&gt;', '>')
    $value = $value.Replace('&quot;', '"')
    $value = $value.Replace('&apos;', "'")
    return $value.Replace('&amp;', '&')
}

# A CDATA section and a comment are payload, not markup. A failure message that
# quotes a suite element would otherwise add phantom suites and phantom rows, so
# both are cut out before anything is split on a bracket. The scan runs left to
# right, which is what keeps a marker inside the other one literal.
function Get-NODecontented {
    param([string]$Text)
    $out = New-Object Text.StringBuilder
    $rest = $Text
    while ($true) {
        $cdata = $rest.IndexOf('<![CDATA[', [StringComparison]::Ordinal)
        $comment = $rest.IndexOf('<!--', [StringComparison]::Ordinal)
        if ($cdata -lt 0 -and $comment -lt 0) { break }
        if ($comment -lt 0 -or ($cdata -ge 0 -and $cdata -lt $comment)) {
            [void]$out.Append($rest.Substring(0, $cdata))
            $rest = $rest.Substring($cdata + 9)
            $close = $rest.IndexOf(']]>', [StringComparison]::Ordinal)
            if ($close -lt 0) { $script:JunitTorn = 'an unterminated CDATA section'; return $out.ToString() }
            $rest = $rest.Substring($close + 3)
        }
        else {
            [void]$out.Append($rest.Substring(0, $comment))
            $rest = $rest.Substring($comment + 4)
            $close = $rest.IndexOf('-->', [StringComparison]::Ordinal)
            if ($close -lt 0) { $script:JunitTorn = 'an unterminated comment'; return $out.ToString() }
            $rest = $rest.Substring($close + 3)
        }
    }
    [void]$out.Append($rest)
    return $out.ToString()
}

# The element text of one record, ending at the first bracket outside a quoted
# attribute value, so an attribute may carry one and the text content never
# reaches the attribute reader.
function Get-NOTagText {
    param([string]$Record)
    $quote = [char]0
    for ($i = 0; $i -lt $Record.Length; $i++) {
        $ch = $Record[$i]
        if ($quote -ne [char]0) {
            if ($ch -eq $quote) { $quote = [char]0 }
            continue
        }
        if ($ch -eq [char]'"' -or $ch -eq [char]"'") { $quote = $ch; continue }
        if ($ch -eq [char]'>') { return $Record.Substring(0, $i) }
    }
    return $Record
}

function Get-NOAttribute {
    param([string]$Record, [string]$Key)
    $match = [regex]::Match($Record, "[ `t`r`n]" + [regex]::Escape($Key) + "=[""']")
    if (-not $match.Success) { return '' }
    $quote = $Record.Substring($match.Index + $match.Length - 1, 1)
    $tail = $Record.Substring($match.Index + $match.Length)
    $end = $tail.IndexOf($quote)
    if ($end -lt 0) { return '' }
    return (Get-NOUnentity $tail.Substring(0, $end))
}

function Get-NOAttributeNumber {
    param([string]$Record, [string]$Key)
    $value = Get-NOAttribute $Record $Key
    if ($value -eq '') { return [long]0 }
    $number = $value -as [double]
    if ($null -eq $number) { return [long]0 }
    return [long][Math]::Floor($number)
}

function Read-NOJunit {
    $bytes = [IO.File]::ReadAllBytes($absolute)
    $text = [Text.Encoding]::GetEncoding(28591).GetString($bytes)
    $suites = [long]0
    $tests = [long]0
    $failures = [long]0
    $errors = [long]0
    $skipped = [long]0
    $saw = $false
    $className = '-'
    $caseName = '-'
    # Only a leaf suite carries counts: a report that nests its suites states the
    # same tests twice, once on the outer suite and once on each suite inside it,
    # and adding both reports every test twice.
    $stack = New-Object 'Collections.Generic.List[object]'
    $script:JunitTorn = ''
    $bad = ''
    $cases = 0
    $body = Get-NODecontented $text
    # The last element of a finished report closes. A document whose final bracket
    # opens a tag and never shuts it was cut off while the writer was still writing.
    $lastOpen = $body.LastIndexOf('<', [StringComparison]::Ordinal)
    if ($lastOpen -ge 0 -and $body.IndexOf('>', $lastOpen, [StringComparison]::Ordinal) -lt 0) {
        $bad = 'a tag that never closes'
    }
    foreach ($record in $body.Split('<')) {
        if ($record -eq '') { continue }
        $tag = Get-NOTagText $record
        $name = $tag
        $closing = $false
        if ($name.StartsWith('/', [StringComparison]::Ordinal)) {
            $closing = $true
            $name = $name.Substring(1)
        }
        $cut = $name.IndexOfAny([char[]]@(' ', "`t", "`r", "`n", '/'))
        if ($cut -ge 0) { $name = $name.Substring(0, $cut) }
        if ($name -ceq 'testsuite') {
            if ($closing) {
                if ($stack.Count -le 0) { $bad = 'a testsuite that closes without opening' }
                $popped = Pop-NOSuite $stack
                if ($null -ne $popped) {
                    $suites++
                    $tests += $popped.Tests
                    $failures += $popped.Failures
                    $errors += $popped.Errors
                    $skipped += $popped.Skipped
                }
                continue
            }
            $saw = $true
            if ($stack.Count -gt 0) { $stack[$stack.Count - 1].Leaf = $false }
            [void]$stack.Add([pscustomobject]@{
                    Tests = (Get-NOAttributeNumber $tag 'tests')
                    Failures = (Get-NOAttributeNumber $tag 'failures')
                    Errors = (Get-NOAttributeNumber $tag 'errors')
                    Skipped = (Get-NOAttributeNumber $tag 'skipped')
                    Leaf = $true
                })
            if ($tag.EndsWith('/', [StringComparison]::Ordinal)) {
                $popped = Pop-NOSuite $stack
                if ($null -ne $popped) {
                    $suites++
                    $tests += $popped.Tests
                    $failures += $popped.Failures
                    $errors += $popped.Errors
                    $skipped += $popped.Skipped
                }
            }
            continue
        }
        if ($name -ceq 'testcase') {
            if ($closing) {
                $cases--
                if ($cases -lt 0) { $bad = 'a testcase that closes without opening' }
                continue
            }
            $className = Get-NOAttribute $tag 'classname'
            $caseName = Get-NOAttribute $tag 'name'
            if ($className -eq '') { $className = '-' }
            if ($caseName -eq '') { $caseName = '-' }
            if (-not $tag.EndsWith('/', [StringComparison]::Ordinal)) { $cases++ }
            continue
        }
        if ($closing) { continue }
        if ($name -ceq 'failure' -or $name -ceq 'error') {
            $type = Get-NOAttribute $tag 'type'
            $detail = $caseName
            if ($type -ne '') { $detail = "$caseName ($type)" }
            Add-NOItem 'error' $className 0 $name $detail
            continue
        }
    }
    # A reader that closes elements nobody closed is answering about a document
    # nobody wrote. An unfinished report is unavailable, with the reason named.
    if ($script:JunitTorn -ne '') { Write-NOUnavailable ('the report ends inside ' + $script:JunitTorn) }
    if ($bad -ne '') { Write-NOUnavailable ('the report holds ' + $bad) }
    if ($stack.Count -gt 0) { Write-NOUnavailable 'the report ends with an unclosed testsuite element' }
    if ($cases -gt 0) { Write-NOUnavailable 'the report ends with an unclosed testcase element' }
    if (-not $saw) { Write-NOUnavailable 'the input holds no JUnit testsuite element' }
    $script:Files = $suites
    $script:Headline = 'junit: ' + (Get-NOPlural $tests 'test') + ', ' +
        (Get-NOPlural $failures 'failure') + ', ' + (Get-NOPlural $errors 'error') +
    ", $skipped skipped in " + (Get-NOPlural $suites 'suite')
    Add-NOCount 'errors' $errors
    Add-NOCount 'failures' $failures
    Add-NOCount 'skipped' $skipped
    Add-NOCount 'tests' $tests
}

# One suite leaves the stack. It comes back when it is a leaf, and $null when a
# suite inside it already spoke for the same tests.
function Pop-NOSuite {
    param($Stack)
    if ($Stack.Count -eq 0) { return $null }
    $entry = $Stack[$Stack.Count - 1]
    $Stack.RemoveAt($Stack.Count - 1)
    if (-not $entry.Leaf) { return $null }
    return $entry
}

# --------------------------------------------------------------- run and render

switch -CaseSensitive ($Format) {
    'eslint-json' { Read-NOEslint }
    'coverage-summary' { Read-NOCoverage }
    'sarif' { Read-NOSarif }
    'npm-audit' { Read-NOAudit }
    'tsc' { Read-NOTsc }
    'lcov' { Read-NOLcov }
    'junit' { Read-NOJunit }
}

# Every row becomes one sortable line: severity rank descending first, then file,
# line, code and detail ascending. A byte sort over that key is the whole order,
# which is why the two engines agree without either sorting objects.
$keys = New-Object 'Collections.Generic.List[string]'
foreach ($item in $script:Items) {
    $severity = Get-NOSanitized $item.Severity
    [void]$keys.Add(
        ('{0}' -f (5 - (Get-NORank $severity))) + "`t" +
        (Get-NOSanitized $item.File) + "`t" +
        ('{0:D9}' -f [long]$item.Line) + "`t" +
        (Get-NOSanitized $item.Code) + "`t" +
        (Get-NOSanitized $item.Detail) + "`t" + $severity)
}
$sorted = [string[]]$keys.ToArray()
[Array]::Sort($sorted, [StringComparer]::Ordinal)

$total = $sorted.Count
$shown = [Math]::Min($Top, $total)
$rows = New-Object 'Collections.Generic.List[object]'
for ($i = 0; $i -lt $shown; $i++) {
    $parts = $sorted[$i].Split("`t")
    [void]$rows.Add([pscustomobject]@{
            Severity = $parts[5]; File = $parts[1]; Line = [long]$parts[2]
            Code = $parts[3]; Detail = $parts[4]
        })
}

$countLabels = [string[]]@($script:Counts | ForEach-Object { $_.Label })
[Array]::Sort($countLabels, [StringComparer]::Ordinal)
$countValues = @{}
foreach ($entry in $script:Counts) { $countValues[$entry.Label] = $entry.Value }

# The result digest covers what the run found - the format, the headline and every
# count, in label order - and nothing about the bytes it read. Two runs that report
# the same numbers therefore carry one digest, and a ledger comparison reads a
# reformatted or rerun report as unchanged rather than as a regression.
$preimage = New-Object Text.StringBuilder
[void]$preimage.Append("normalize-output`t1`t" + $Format + "`t" + $script:Headline + "`n")
foreach ($label in $countLabels) {
    [void]$preimage.Append($label + "`t" + [string]$countValues[$label] + "`n")
}
$digest = Get-NSTextSha256 $preimage.ToString()

$displayPath = Get-NOPathText $InputPath

# The JSON body names the file, not the path that reached it: a summary written
# from a temporary checkout has to compare against one written from a clone.
$leaf = $InputPath
$cut = [Math]::Max($leaf.LastIndexOf('/'), $leaf.LastIndexOf('\'))
if ($cut -ge 0) { $leaf = $leaf.Substring($cut + 1) }
$basename = Get-NOPathText $leaf

if ($Json) {
    $text = '{"counts":{'
    for ($i = 0; $i -lt $countLabels.Count; $i++) {
        if ($i -gt 0) { $text += ',' }
        $text += '"' + (Get-NOJsonEscaped $countLabels[$i]) + '":' + [string]$countValues[$countLabels[$i]]
    }
    $text += '},"digest":"' + (Get-NOJsonEscaped $digest) + '","files":' + [string]$script:Files
    $text += ',"format":"' + (Get-NOJsonEscaped $Format) + '","headline":"' +
        (Get-NOJsonEscaped $script:Headline) + '"'
    $text += ',"input":"' + (Get-NOJsonEscaped $basename) + '","items":['
    for ($i = 0; $i -lt $rows.Count; $i++) {
        if ($i -gt 0) { $text += ',' }
        $line = 'null'
        if ($rows[$i].Line -ne 0) { $line = [string]$rows[$i].Line }
        $text += '{"code":"' + (Get-NOJsonEscaped $rows[$i].Code) + '","file":"' +
            (Get-NOJsonEscaped $rows[$i].File) + '","line":' + $line + ',"message":"' +
            (Get-NOJsonEscaped $rows[$i].Detail) + '","severity":"' +
            (Get-NOJsonEscaped $rows[$i].Severity) + '"}'
    }
    $text += '],"shown":' + [string]$rows.Count + ',"source":"' +
        (Get-NOJsonEscaped $sourceDigest) + '","total":' + [string]$total + ',"version":1}'
    Write-NOLine $text
    Complete-NOOutput
    exit 0
}

Write-NOLine $script:Headline
Write-NOLine ''
if ($rows.Count -gt 0) {
    Write-NOLine '| severity | file | line | code | detail |'
    Write-NOLine '| --- | --- | --- | --- | --- |'
    foreach ($row in $rows) {
        $line = '-'
        if ($row.Line -ne 0) { $line = [string]$row.Line }
        Write-NOLine ('| ' + (Get-NOCellEscaped $row.Severity) + ' | ' + (Get-NOCellEscaped $row.File) +
            ' | ' + $line + ' | ' + (Get-NOCellEscaped $row.Code) + ' | ' +
            (Get-NOCellEscaped $row.Detail) + ' |')
    }
    Write-NOLine ''
}
Write-NOLine ("showing {0} of {1} items" -f $rows.Count, $total)
Write-NOLine ("result: sha256:{0}" -f $digest)
Write-NOLine ("source: {0} sha256:{1}" -f $displayPath, $sourceDigest)
Complete-NOOutput
exit 0

SHA-256: 5fb104060b3dad1ddd171a5dd61cb291528a7de7a70a9a85a904f2d262638244