Private/Kinds/StabilityCrash.ps1

# A program's crashes: the Findings about them, the indicators read off them and the
# verdict drawn from those. A part of the Stability Kind, and no Kind.

# Where each exception type sends a Technician. Keyed by the type's own name without its
# namespace, because SqlException lives in more than one and means the same in each. A
# type that is not here is the application's own business. See Get-ExceptionTypeHint.
$script:ExceptionTypeDirections = @{
    'SEHException'                = 'Native'
    'AccessViolationException'    = 'Native'
    'FileLoadException'           = 'Load'
    'FileNotFoundException'       = 'Load'
    'BadImageFormatException'     = 'Load'
    'IOException'                 = 'FileAccess'
    'UnauthorizedAccessException' = 'FileAccess'
    'SqlException'                = 'Database'
    'TimeoutException'            = 'Database'
    'OutOfMemoryException'        = 'Memory'
}

function Get-ExceptionTypeDirection {
    <#
    .SYNOPSIS
        The exception types in these .NET rows, by where they point: Native, Load,
        FileAccess, Database, Memory, and Other for a type that points nowhere. Pure.
    .DESCRIPTION
        What Get-ExceptionTypeMeaning and Get-ExceptionTypeHint both go by, so that what
        a type is said to mean and what is said to do about it are about the same types.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()][AllowEmptyCollection()]$RuntimeFault)

    $found = @{}
    foreach ($row in @($RuntimeFault)) {
        foreach ($type in (Get-DataCollection $row 'ExceptionTypes')) {
            $name = @("$type" -split '[.+]')[-1]
            if (-not $name) { continue }

            $direction = $script:ExceptionTypeDirections[$name]
            if (-not $direction) { $direction = 'Other' }

            if (-not $found.ContainsKey($direction)) { $found[$direction] = @() }
            if ($found[$direction] -notcontains $name) { $found[$direction] += $name }
        }
    }
    $found
}

function Get-ExceptionTypeMeaning {
    <#
    .SYNOPSIS
        What the exception types in these .NET rows mean, one sentence per direction.
        Empty when they name none. Pure.
    .DESCRIPTION
        The counterpart of Get-ExceptionTypeHint, with the same rule: "the application's
        own fault" is said only when no type points anywhere else.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$RuntimeFault)

    $found = Get-ExceptionTypeDirection -RuntimeFault $RuntimeFault
    if (-not $found.Count) { return '' }

    $means = @()
    if ($found.ContainsKey('Native'))     { $means += Get-Text 'Meaning.Stability.ExceptionNative'     ($found['Native'] -join ', ') }
    if ($found.ContainsKey('Load'))       { $means += Get-Text 'Meaning.Stability.ExceptionLoad'       ($found['Load'] -join ', ') }
    if ($found.ContainsKey('FileAccess')) { $means += Get-Text 'Meaning.Stability.ExceptionFileAccess' ($found['FileAccess'] -join ', ') }
    if ($found.ContainsKey('Database'))   { $means += Get-Text 'Meaning.Stability.ExceptionDatabase'   ($found['Database'] -join ', ') }
    if ($found.ContainsKey('Memory'))     { $means += Get-Text 'Meaning.Stability.ExceptionMemory'     ($found['Memory'] -join ', ') }

    if (-not $means.Count) {
        $means += Get-Text 'Meaning.Stability.ExceptionOther' (@($found['Other'] | Select-Object -First 3) -join ', ')
    }
    $means -join ' | '
}

function Get-ExceptionTypeHint {
    <#
    .SYNOPSIS
        Where the exception types in these .NET rows point, as a Hint. Empty when they
        name none.
    .DESCRIPTION
        An exception code says how a program died; the type says where to look. One
        sentence per direction, each naming the types that point in it.
 
        "The application's own fault" is said only when no type points anywhere else: the
        runtime names the wrapper beside the cause, and a TypeInitializationException
        around a FileNotFoundException is a file that was not there.
 
        Part of the Judge, and used by the App Kind's as well: it is handed rows and reads
        nothing.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$RuntimeFault)

    $found = Get-ExceptionTypeDirection -RuntimeFault $RuntimeFault
    if (-not $found.Count) { return '' }

    $hints = @()
    if ($found.ContainsKey('Native'))     { $hints += Get-Text 'Hint.Stability.ExceptionNative'     ($found['Native'] -join ', ') }
    if ($found.ContainsKey('Load'))       { $hints += Get-Text 'Hint.Stability.ExceptionLoad'       ($found['Load'] -join ', ') }
    if ($found.ContainsKey('FileAccess')) { $hints += Get-Text 'Hint.Stability.ExceptionFileAccess' ($found['FileAccess'] -join ', ') }
    if ($found.ContainsKey('Database'))   { $hints += Get-Text 'Hint.Stability.ExceptionDatabase'   ($found['Database'] -join ', ') }
    if ($found.ContainsKey('Memory'))     { $hints += Get-Text 'Hint.Stability.ExceptionMemory'     ($found['Memory'] -join ', ') }

    if (-not $hints.Count) {
        # The first few: a machine with many applications names many types of its own.
        $hints += Get-Text 'Hint.Stability.ExceptionOther' (@($found['Other'] | Select-Object -First 3) -join ', ')
    }
    $hints -join ' | '
}

function New-ApplicationCrashFinding {
    <#
    .SYNOPSIS
        One Finding for each program that crashes repeatedly, one for the rest, and one OK
        Finding for a machine on which nothing crashed.
    .DESCRIPTION
        A count over all applications names no program, and a Technician had to open the
        event log to learn that all nine crashes were one. Each program now speaks for
        itself, whether or not anybody wrote a Check Definition for it.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters, [AllowNull()]$Situation)

    $programs = @(Get-CrashingProgram -Data $Data -Parameters $Parameters)
    if (-not $programs.Count) {
        return New-Finding -Category Stability -Check (Get-Text 'Check.Stability.ApplicationCrashesAllApps') `
            -Severity OK -Value 0
    }

    $days = Get-DataProperty $Data 'Days'
    if ($null -eq $days) { $days = $script:StabilityDefaultDays }

    # An access violation in one program is that program. In several that have nothing to
    # do with each other it is what they share: the memory they run in.
    $spreadOver = Get-NumberParameter $Parameters 'AccessViolationSpreadPrograms' 3
    $violating  = @($programs | Where-Object { @($_.Incidents | Where-Object { "$(Get-DataProperty $_ 'Exception')" -match '(?i)c0000005' }).Count })
    $spread     = $violating.Count -ge $spreadOver

    foreach ($program in @($programs | Where-Object { $_.Repeated })) {
        # A program that an application's own Check judges in this Run is said here, and
        # judged there. Seen: a helper's four crashes as a WARN of its own with "0 of 3
        # fell together with a disruption of the network", beside the Check that knew
        # they fell together with its host going away - one fault, two alarms, and the
        # one that knew less sent the Technician to look at the network.
        $covered = Get-CrashCoveringApp -App $program.App -Covered (Get-DataCollection $Data 'CoveredPrograms')
        if ($covered) {
            New-ProgramCrashFinding -App $program.App -Incident $program.Incidents -Days $days -Parameters $Parameters -CoveredBy $covered
            continue
        }
        $evidence = Get-ProgramCrashEvidence -App $program.App -Incident $program.Incidents -Data $Data -Parameters $Parameters -Situation $Situation
        $signal = @()
        if ($spread -and $violating -contains $program) { $signal = @('access-violations-spread') }
        # Held to what the Finding itself says of it: a program known to be local whose
        # crashes fell near a disruption once is not said to have crashed with the network.
        if (Test-CorrelationWorthALook -Correlation $evidence.Correlation) { $signal += 'crash-with-network-disruption' }

        # Whether this program's crashes began shortly after a Change. No part of the
        # verdict, which weighs what the crashes themselves say: it is a date beside them.
        $tie = Get-CrashChangeTie -Time @($program.Incidents | ForEach-Object { Get-DataProperty $_ 'Time' }) `
            -Change (Get-DataProperty $Data 'Change') -App $program.App -Log 'Application' -Days $days -Parameters $Parameters
        $began = @()
        if ($null -ne $tie) { $began = @(Get-Text 'Value.Stability.Change.FirstIncident' $tie.First (Get-ChangeTieText -Tie $tie)) }

        # The verdict last of what it is drawn from, because it is what the lines before it
        # add up to; and its Hint first, because it is what to do. What it means goes in
        # the order of the Hints. The Change after all of it.
        New-ProgramCrashFinding -App $program.App -Incident $program.Incidents -Days $days -Parameters $Parameters `
            -Network $evidence.Network -Severity $evidence.Verdict.Severity `
            -Line (@(Get-ProgramCorrelationLine -Correlation $evidence.Correlation) + @(Get-ProgramVerdictLine -Verdict $evidence.Verdict) + $began) `
            -Meaning (@($evidence.Verdict.Meaning) + @(Get-ProgramCorrelationMeaning -Correlation $evidence.Correlation) + @(Get-ChangeTieMeaning -Tie $tie)) `
            -Hint (@($evidence.Verdict.Hint) + @(Get-ProgramCorrelationHint -Correlation $evidence.Correlation -Situation $Situation) + @(Get-ChangeTieHint -Tie $tie)) `
            -Signal $signal -Reference @(Get-ChangeTieReference -Tie $tie)
    }

    $scattered = @($programs | Where-Object { -not $_.Repeated })
    $signal = @()
    if ($spread -and @($scattered | Where-Object { $violating -contains $_ }).Count) { $signal = @('access-violations-spread') }
    New-ScatteredCrashFinding -Program $scattered -Parameters $Parameters -Signal $signal
}

function Get-CrashCoveringApp {
    <#
    .SYNOPSIS
        The application whose own Check judges a program in this Run, by the pattern of
        its processes; nothing where none does. Pure.
    .DESCRIPTION
        An event names a program with its extension and a pattern is written without, as
        Select-AppRow reads them. A pattern that is no regular expression covers nothing.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$App, [AllowNull()][AllowEmptyCollection()]$Covered)

    $name = "$App".Trim() -replace '\.exe$', ''
    if (-not $name) { return '' }
    foreach ($one in @($Covered | Where-Object { $_ })) {
        $pattern = "$(Get-DataProperty $one 'ProcessPattern')"
        $owner   = "$(Get-DataProperty $one 'App')".Trim()
        if (-not $pattern -or -not $owner) { continue }
        try { if ($name -match $pattern) { return $owner } } catch { continue }
    }
    ''
}

function Get-CrashingProgram {
    <#
    .SYNOPSIS
        The programs that crashed, each with its incidents, the one that crashed most
        first. Pure.
    .DESCRIPTION
        Repeated says whether the program reached the number of incidents from which it
        gets a Finding of its own. Three by default: one or two crashes in a month happen
        to healthy programs, and three is the smallest number at which crashes can be said
        to be spread over several places - so every program that can earn that statement
        has a Finding to carry it.
 
        A crash whose program no event named is kept, under no name, and never counts as
        repeated: nothing says two of those were the same program.
 
        Also for a later Check that knows more about these crashes than this one does, so
        that it judges the same programs and the same incidents. The rows are written to
        the pipeline one by one: collect them with @( ).
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{})

    $threshold = Get-NumberParameter $Parameters 'ProgramIncidentThreshold' 3
    $window    = Get-IncidentWindowSetting -Parameters $Parameters

    $crashes = Select-CrashIncident -Incident (Get-StabilityIncident -Data $Data -WindowSeconds $window)

    $byProgram = @{}
    $order     = @()
    foreach ($incident in $crashes) {
        $key = "$(Get-DataProperty $incident 'App')".Trim().ToLowerInvariant()
        if (-not $byProgram.ContainsKey($key)) { $byProgram[$key] = @(); $order += $key }
        $byProgram[$key] += $incident
    }

    $programs = foreach ($key in $order) {
        $incidents = @($byProgram[$key] | Sort-Object @{ Expression = { Get-IncidentTimeKey $_ } } -Descending)
        [pscustomobject]@{
            # As the newest incident writes it.
            App       = "$(Get-DataProperty $incidents[0] 'App')".Trim()
            Incidents = $incidents
            Repeated  = [bool]($key -and $incidents.Count -ge $threshold)
        }
    }

    $programs | Sort-Object @{ Expression = { $_.Incidents.Count }; Descending = $true },
                            @{ Expression = { Get-IncidentTimeKey $_.Incidents[0] }; Descending = $true }
}

function Get-IncidentTimeKey {
    <#
    .SYNOPSIS
        An incident's time as something to sort by: the earliest date there is when it has none.
    #>

    [CmdletBinding()]
    [OutputType([datetime])]
    param([AllowNull()]$Incident)

    $time = ConvertTo-DataTime (Get-DataProperty $Incident 'Time')
    if ($null -eq $time) { return [datetime]::MinValue }
    $time
}

function Get-CountedText {
    <#
    .SYNOPSIS
        Names, counted: "6x this, 2x that", the most frequent first. Pure.
    .DESCRIPTION
        Names that are equally frequent stay in the order they were handed over, which
        Sort-Object alone does not promise on Windows PowerShell 5.1.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Name, [int]$First = 0)

    $counts = @{}
    $order  = @()
    foreach ($item in @($Name | Where-Object { $_ })) {
        if (-not $counts.ContainsKey($item)) { $counts[$item] = 0; $order += $item }
        $counts[$item]++
    }

    if (-not $order.Count) { return '' }

    $ranked = @(for ($index = 0; $index -lt $order.Count; $index++) {
        [pscustomobject]@{ Name = $order[$index]; Count = $counts[$order[$index]]; Index = $index }
    }) | Sort-Object @{ Expression = { $_.Count }; Descending = $true }, @{ Expression = { $_.Index } }
    $ranked = @($ranked)

    $shown = $ranked
    if ($First -gt 0 -and $ranked.Count -gt $First) { $shown = @($ranked | Select-Object -First $First) }

    $text = @($shown | ForEach-Object { Get-Text 'Value.Stability.Program.Counted' $_.Count $_.Name }) -join ', '
    if ($shown.Count -lt $ranked.Count) {
        $text = Get-Text 'Value.Stability.ScatteredMore' $text ($ranked.Count - $shown.Count)
    }
    $text
}

function Get-ExceptionCodeText {
    <#
    .SYNOPSIS
        An exception code as a Technician reads it: 0xc0000006, and what it means where
        that is known. Pure.
    .DESCRIPTION
        The codes that name a cause outright. c0000006 above all: a page of the program
        could not be read back in, which is what a program on a share does when the
        connection drops.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Code)

    $plain = ("$Code".Trim() -replace '^0x', '').ToLowerInvariant()
    if (-not $plain) { return '' }

    $meaning = switch ($plain) {
        'c0000005' { Get-Text 'Value.Stability.Code.AccessViolation' }
        'c0000006' { Get-Text 'Value.Stability.Code.InPage' }
        'c0000017' { Get-Text 'Value.Stability.Code.NoMemory' }
        'c00000fd' { Get-Text 'Value.Stability.Code.StackOverflow' }
        'c0000374' { Get-Text 'Value.Stability.Code.HeapCorruption' }
        'c0000409' { Get-Text 'Value.Stability.Code.FailFast' }
        # STATUS_FATAL_USER_CALLBACK_EXCEPTION: an exception nobody caught inside a
        # callback of the window system, as ntstatus.h names it.
        'c000041d' { Get-Text 'Value.Stability.Code.UserCallback' }
        'e0434352' { Get-Text 'Value.Stability.Code.DotNet' }
        'e06d7363' { Get-Text 'Value.Stability.Code.Cpp' }
        # Any other status of Windows, as Windows says it. Not the codes programs make
        # up for themselves (0xE...): those are no status and have no sentence.
        default    { if ($plain -match '^[c8]0[0-9a-f]{6}$') { Get-NtStatusText -Code "0x$plain" } else { '' } }
    }

    if ($meaning) { return '0x{0} ({1})' -f $plain, $meaning }
    '0x{0}' -f $plain
}

function Get-FaultSite {
    <#
    .SYNOPSIS
        The distinct places in a program at which these incidents happened. Pure.
    .DESCRIPTION
        The place is the first method of the stack where the .NET runtime named one, and
        the faulting module where it did not: every .NET crash faults in clr.dll or
        KERNELBASE.dll, so the module says nothing there, and a native program has no
        method. An incident that names neither has no place and is not counted as one.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Incident)

    $sites = @()
    foreach ($row in @($Incident | Where-Object { $null -ne $_ })) {
        $site = Get-IncidentFaultSite -Incident $row
        if ($site -and $sites -notcontains $site) { $sites += $site }
    }
    $sites
}

function Get-IncidentFaultSite {
    <#
    .SYNOPSIS
        The place in the program at which one incident happened, or nothing when no event
        of it named one. Pure. See Get-FaultSite.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Incident)

    $method = "$(Get-DataProperty $Incident 'Method')".Trim()
    if ($method) { return ('method:{0}' -f $method) }

    $module = "$(Get-DataProperty $Incident 'Module')".Trim()
    if ($module) { return ('module:{0}' -f $module.ToLowerInvariant()) }
    ''
}

function Test-FaultSiteSpread {
    <#
    .SYNOPSIS
        Whether these incidents are spread over too many places to be one bug. Pure.
    .DESCRIPTION
        A bug crashes a program in the same place every time. A program that dies in a
        different place each time is being failed by something beneath it: the share it
        runs from, the disk, the memory, a driver, a virus scanner. From three incidents at
        three places by default; both numbers are Check Definition parameters.
 
        And no one place may hold more than half of the incidents that have one, which is
        the third parameter. Twenty-five crashes at one method and three elsewhere are
        three places, and a program that dies in one place: a bug, with three strays.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyCollection()]$Incident, [hashtable]$Parameters = @{})

    $needIncidents = Get-NumberParameter $Parameters 'FaultSiteSpreadIncidents' 3
    $needSites     = Get-NumberParameter $Parameters 'FaultSiteSpreadSites' 3

    $dominance     = Get-NumberParameter $Parameters 'FaultSiteDominancePercent' 50

    $incidents = @($Incident | Where-Object { $null -ne $_ })
    if ($incidents.Count -lt $needIncidents) { return $false }

    $perSite = @{}
    $placed  = 0
    $most    = 0
    foreach ($row in $incidents) {
        $site = Get-IncidentFaultSite -Incident $row
        if (-not $site) { continue }
        if (-not $perSite.ContainsKey($site)) { $perSite[$site] = 0 }
        $perSite[$site]++
        $placed++
        if ($perSite[$site] -gt $most) { $most = $perSite[$site] }
    }
    if (-not $placed -or $perSite.Count -lt $needSites) { return $false }

    ($most * 100 / $placed) -le $dominance
}

function ConvertTo-ProgramKey {
    <#
    .SYNOPSIS
        A program's name as something to compare by. Pure.
    .DESCRIPTION
        An event names a program acmesync.exe or ACMESYNC.EXE, and the inventory of what
        depends on the network names the process, acmesync.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$App)

    "$App".Trim().ToLowerInvariant() -replace '\.exe$', ''
}

function ConvertTo-IndicatorWeight {
    <#
    .SYNOPSIS
        A weight as a Check Definition writes it, as one of high, medium, low and off. Pure.
    .DESCRIPTION
        "off" takes an indicator out of the verdict altogether. Anything that is none of
        the four is the default: a mistyped weight must not switch an indicator off.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Value, [Parameter(Mandatory)][string]$Default)

    $weight = "$Value".Trim().ToLowerInvariant()
    if (@('high', 'medium', 'low', 'off') -contains $weight) { return $weight }
    $Default
}

function New-CrashIndicator {
    <#
    .SYNOPSIS
        One indicator of a verdict: what it is, how much it weighs, and whether it was
        present, absent or could not be looked at. Pure.
    .PARAMETER Text
        What the Finding lists when it is present.
    .PARAMETER Name
        What the Finding lists when it could not be assessed. Empty for an indicator that
        always can be, because it is read off the crashes themselves.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Key,
        [Parameter(Mandatory)][string]$Weight,
        [Parameter(Mandatory)][ValidateSet('Present', 'Absent', 'NotAssessed')][string]$State,
        [AllowEmptyString()][string]$Text = '',
        [AllowEmptyString()][string]$Name = ''
    )

    [pscustomobject]@{ Key = $Key; Weight = $Weight; State = $State; Text = $Text; Name = $Name }
}

function Get-ProgramCrashIndicator {
    <#
    .SYNOPSIS
        The indicators a program's verdict is drawn from, each weighed and each said to be
        present, absent or not assessed. Pure.
    .DESCRIPTION
        Three weigh heavily by default, because each on its own is what a program on a
        share that lost its connection looks like:
 
          NetworkPath the program, or a module the inventory lists for it, lives on
                            a network path
          NetworkException an incident shows an exception code or a .NET exception type
                            that says a page or a file could not be read: 0xc0000006,
                            SEHException, and the three ways an assembly fails to load
          Disruption an incident coincided with a network disruption: one in its
                            window, or a reconnect to the program's Server shortly after
                            it. See Get-ProgramCorrelation.
 
        Two weigh less, because they are true of machines on which nothing is wrong:
 
          FaultSiteSpread the crashes are spread over too many places to be one bug
          UsbAdapter an adapter that is up is on USB - a dock, a dongle. Its being
                            on USB is weighed, and not its power saving: that is on by
                            default nearly everywhere, and says so in the text when it is.
 
        Not assessed is not absent. NetworkPath is not assessed when nothing places the
        program on the network and nobody took the inventory, or when a path of it is on
        a drive that is neither mapped nor a disk of the machine; Disruption when nobody
        could look for disruptions, or not at every incident - a log not read, an incident
        from before the logs begin; UsbAdapter when the Network Check gathered no
        adapters, or gathered them before it knew which are on USB.
 
        Disruption is present when the correlation says what coincided weighs. For a
        program on the network one incident is enough; for one known to be local it takes
        a share of them. See Test-CorrelationWeight.
 
        Every weight is a Check Definition parameter, and so is what stands behind each
        indicator. A weight of "off" leaves an indicator out.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowNull()]$Network,
        [AllowNull()]$Correlation,
        [AllowNull()]$Adapters,
        [hashtable]$Parameters = @{}
    )

    $incidents = @($Incident | Where-Object { $null -ne $_ })

    # The program or one of its modules on a network path.
    $weight = ConvertTo-IndicatorWeight (Get-Parameter $Parameters 'IndicatorWeightNetworkPath' 'high') -Default 'high'
    $places = Get-DataCollection $Network 'Places'
    $drives = Get-DataCollection $Network 'UnknownDrives'
    $state  = 'Absent'
    $text   = ''
    $name   = Get-Text 'Value.Stability.Indicator.Name.NetworkPath'
    if ($places.Count) { $state = 'Present'; $text = Get-Text 'Value.Stability.Indicator.NetworkPath' ($places -join ', ') }
    elseif ($drives.Count) {
        # A drive letter that is neither mapped nor a disk of this machine: nothing says
        # the program is local, and it is not judged as if something did.
        $state = 'NotAssessed'
        $name  = Get-Text 'Value.Stability.Indicator.Name.NetworkPathDrive' $name ($drives -join ', ')
    }
    elseif (-not [bool](Get-DataProperty $Network 'Inventoried')) { $state = 'NotAssessed' }
    New-CrashIndicator -Key 'NetworkPath' -Weight $weight -State $state -Text $text -Name $name

    # An exception that is what a lost share looks like. An empty list in a Check
    # Definition is a list left out, not "none": see Get-ListParameter.
    $weight = ConvertTo-IndicatorWeight (Get-Parameter $Parameters 'IndicatorWeightNetworkException' 'high') -Default 'high'
    $codes  = @((Get-ListParameter $Parameters 'NetworkExceptionCodes' @('c0000006')) |
        ForEach-Object { ($_ -replace '^0x', '').ToLowerInvariant() } | Where-Object { $_ })
    $types  = (Get-ListParameter $Parameters 'NetworkExceptionTypes' @(
        'SEHException', 'FileLoadException', 'FileNotFoundException', 'BadImageFormatException'))
    $needed = Get-NumberParameter $Parameters 'NetworkExceptionIncidents' 1

    $showing    = 0
    $seenCodes  = @()
    $seenTypes  = @()
    foreach ($row in $incidents) {
        $shows = $false
        $code  = ("$(Get-DataProperty $row 'Exception')".Trim() -replace '^0x', '').ToLowerInvariant()
        if ($code -and @($codes) -contains $code) {
            $shows = $true
            if ($seenCodes -notcontains ('0x{0}' -f $code)) { $seenCodes += '0x{0}' -f $code }
        }
        foreach ($type in (Get-DataCollection $row 'ExceptionTypes')) {
            $short = @("$type" -split '[.+]')[-1]
            if ($short -and @($types) -contains $short) {
                $shows = $true
                if ($seenTypes -notcontains $short) { $seenTypes += $short }
            }
        }
        if ($shows) { $showing++ }
    }
    $state = 'Absent'
    $text  = ''
    if ($showing -gt 0 -and $showing -ge $needed) {
        $state = 'Present'
        $text  = Get-Text 'Value.Stability.Indicator.NetworkException' ((@($seenCodes) + @($seenTypes)) -join ', ')
    }
    New-CrashIndicator -Key 'NetworkException' -Weight $weight -State $state -Text $text

    # A network disruption in an incident's window.
    # Whether what coincided is enough is the correlation's to say: of a program known to
    # be local it takes more than one chance coincidence. See Test-CorrelationWeight.
    $weight     = ConvertTo-IndicatorWeight (Get-Parameter $Parameters 'IndicatorWeightDisruption' 'high') -Default 'high'
    $correlated = ConvertTo-Number (Get-DataProperty $Correlation 'Correlated')
    if ($null -eq $correlated) { $correlated = 0 }
    $total = ConvertTo-Number (Get-DataProperty $Correlation 'Total')
    if ($null -eq $total) { $total = 0 }

    $weighs = [bool](Get-DataProperty $Correlation 'Weighs')
    if (-not (Test-DataCarried -InputObject $Correlation -Name 'Weighs')) {
        $weighs = Test-CorrelationWeight -Correlated $correlated -Total $total `
            -KnownLocal ([bool](Get-DataProperty $Network 'KnownLocal')) -Parameters $Parameters
    }

    # Not assessed wherever an incident was not, or not wholly, looked at: one that lies
    # before the logs begin might have been the one that coincided.
    $open = [bool]($null -eq $Correlation -or (Get-DataProperty $Correlation 'State') -eq 'NotAssessed' -or
        [bool](Get-DataProperty $Correlation 'LogsUnread') -or
        (ConvertTo-Number (Get-DataProperty $Correlation 'Before')) -gt 0 -or
        (ConvertTo-Number (Get-DataProperty $Correlation 'Untimed')) -gt 0)
    # Of a program the network does not concern, a disruption nobody could look for is
    # not a gap in the evidence. See Test-ProgramNetworkConcern.
    if ([bool](Get-DataProperty $Correlation 'SetAside')) { $open = $false }

    $state = 'Absent'
    $text  = ''
    if ($weighs) {
        $state = 'Present'
        $text  = Get-Text 'Value.Stability.Indicator.Disruption' $correlated $total
    }
    elseif ($open) { $state = 'NotAssessed' }
    New-CrashIndicator -Key 'Disruption' -Weight $weight -State $state -Text $text `
        -Name (Get-Text 'Value.Stability.Indicator.Name.Disruption')

    # Fault sites spread too widely to be one bug.
    $weight = ConvertTo-IndicatorWeight (Get-Parameter $Parameters 'IndicatorWeightFaultSiteSpread' 'medium') -Default 'medium'
    $state  = 'Absent'
    $text   = ''
    if (Test-FaultSiteSpread -Incident $incidents -Parameters $Parameters) {
        $state = 'Present'
        $text  = Get-Text 'Value.Stability.Indicator.FaultSiteSpread' (@(Get-FaultSite -Incident $incidents).Count)
    }
    New-CrashIndicator -Key 'FaultSiteSpread' -Weight $weight -State $state -Text $text

    # An adapter on USB.
    $weight  = ConvertTo-IndicatorWeight (Get-Parameter $Parameters 'IndicatorWeightUsbAdapter' 'medium') -Default 'medium'
    $rows    = Get-DataCollection $Adapters 'Adapters'
    $onUsb   = @($rows | Where-Object { (Get-DataProperty $_ 'IsUsb') -eq $true })
    $unknown = @($rows | Where-Object { $null -eq (Get-DataProperty $_ 'IsUsb') })
    $state   = 'Absent'
    $text    = ''
    if ($onUsb.Count) {
        $state = 'Present'
        $names = @($onUsb | ForEach-Object {
            $name = "$(Get-DataProperty $_ 'Description')".Trim()
            if (-not $name) { $name = "$(Get-DataProperty $_ 'Name')".Trim() }
            $name
        } | Where-Object { $_ }) -join ', '

        if (@($onUsb | Where-Object { (Get-DataProperty $_ 'PowerSavingOn') -eq $true }).Count) {
            $text = Get-Text 'Value.Stability.Indicator.UsbAdapterSaving' $names
        }
        else { $text = Get-Text 'Value.Stability.Indicator.UsbAdapter' $names }

        if ((Get-DataProperty $Adapters 'UsbSelectiveSuspend') -eq $true) { $text = Get-Text 'Value.Stability.Indicator.UsbSuspend' $text }
    }
    elseif (-not [bool](Get-DataProperty $Adapters 'Gathered') -or $unknown.Count) { $state = 'NotAssessed' }
    New-CrashIndicator -Key 'UsbAdapter' -Weight $weight -State $state -Text $text `
        -Name (Get-Text 'Value.Stability.Indicator.Name.UsbAdapter')
}

function Get-VerdictOutcome {
    <#
    .SYNOPSIS
        What a number of heavy and of lighter indicators amounts to. Pure.
    .DESCRIPTION
        Cause enough heavy indicators to name the cause: two by default
        Suspicion one heavy indicator and at least one lighter one
        Lead one heavy indicator, and nothing beside it
        Environment no heavy indicator, and fault sites spread too widely to be one bug:
                     something around the program, and nothing says it is the network
        Application none of the above: the application's own fault
 
        Only the spread makes "Environment" of lighter indicators without a heavy one. An
        adapter on USB is a reason to suspect the network of a program that depends on
        it, and none to doubt a program that does not.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [double]$High, [double]$Medium, [bool]$Spread,
        [double]$FailHigh, [double]$WarnHigh, [double]$SuspicionMedium
    )

    if ($High -gt 0 -and $High -ge $FailHigh) { return 'Cause' }
    if ($High -gt 0 -and $High -ge $WarnHigh) {
        if ($Medium -gt 0 -and $Medium -ge $SuspicionMedium) { return 'Suspicion' }
        return 'Lead'
    }
    if ($Spread) { return 'Environment' }
    'Application'
}

function Get-MostFrequent {
    <#
    .SYNOPSIS
        The value that occurs most often, the first of them when several do. Empty when
        there is none. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Value)

    $counts = @{}
    $best   = ''
    foreach ($item in @($Value | Where-Object { $_ })) {
        if (-not $counts.ContainsKey($item)) { $counts[$item] = 0 }
        $counts[$item]++
        # Only a value that overtakes replaces the one found first.
        if (-not $best -or $counts[$item] -gt $counts[$best]) { $best = $item }
    }
    $best
}

function Get-ProgramVendorText {
    <#
    .SYNOPSIS
        What the vendor of a program needs to find its fault: the program and its version,
        the module that faulted most often with its version, and the method. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowEmptyString()][string]$App = '', [AllowNull()][AllowEmptyCollection()]$Incident)

    $incidents = @($Incident | Where-Object { $null -ne $_ })

    # The version of the newest incident that names one: the incidents come newest first.
    $version = @($incidents | ForEach-Object { "$(Get-DataProperty $_ 'Version')".Trim() } | Where-Object { $_ } | Select-Object -First 1)
    $parts   = @(('{0} {1}' -f $App, "$version").Trim())

    $module = Get-MostFrequent -Value @($incidents | ForEach-Object { "$(Get-DataProperty $_ 'Module')".Trim() })
    if ($module) {
        $moduleVersion = @($incidents | Where-Object { "$(Get-DataProperty $_ 'Module')".Trim() -eq $module } |
            ForEach-Object { "$(Get-DataProperty $_ 'ModuleVer')".Trim() } | Where-Object { $_ } | Select-Object -First 1)
        $parts += (Get-Text 'Value.Stability.Verdict.VendorModule' $module "$moduleVersion").Trim()
    }

    $method = Get-MostFrequent -Value @($incidents | ForEach-Object { "$(Get-DataProperty $_ 'Method')".Trim() })
    if ($method) { $parts += Get-Text 'Value.Stability.Verdict.VendorMethod' $method }

    @($parts | Where-Object { $_ }) -join ', '
}

function Get-ProgramCrashVerdict {
    <#
    .SYNOPSIS
        The verdict on one program's crashes, from a chain of evidence. Pure.
    .DESCRIPTION
        Returns Severity, Sentence, Present and NotAssessed, and beside them Outcome,
        Incomplete, Hint and Meaning. The Sentence is the verdict and part of the Value;
        Meaning is what follows from it and the Sentence does not say.
 
        Two heavy indicators are a FAIL whose Sentence names the cause, and the share when
        there is one. One heavy indicator is a WARN: a suspicion when a lighter one stands
        beside it, and otherwise a single lead that is said to be one. None is the
        application's own fault, with what its vendor needs - or, when the crashes are
        spread over too many places to be one bug, something around the program. For
        those two Severity is empty: the Finding stays graded on the number of crashes.
 
        The three numbers are Check Definition parameters, as every weight is.
 
        An indicator that could not be looked at is listed in NotAssessed and counted as
        nothing - neither present nor absent. When the ones that are missing could have
        made another verdict of it, the Sentence says the evidence is incomplete: a
        "this is the application's fault" on a machine whose network nobody could look at
        is a guess, and must not read like a finding.
 
        Present and NotAssessed list the indicators with their weight, heaviest first, so
        that second level can check the reasoning.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowEmptyString()][string]$App = '',
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowNull()]$Network,
        [AllowNull()]$Correlation,
        [AllowNull()]$Adapters,
        [hashtable]$Parameters = @{},
        [AllowNull()]$Situation
    )

    $failHigh        = Get-NumberParameter $Parameters 'VerdictFailHighIndicators' 2
    $warnHigh        = Get-NumberParameter $Parameters 'VerdictWarnHighIndicators' 1
    $suspicionMedium = Get-NumberParameter $Parameters 'VerdictSuspicionMediumIndicators' 1

    $indicators = @(Get-ProgramCrashIndicator -Incident $Incident -Network $Network -Correlation $Correlation `
        -Adapters $Adapters -Parameters $Parameters)

    # Heaviest first, and within one weight in the order they were looked at.
    $present = @()
    $open    = @()
    $count   = @{ high = 0; medium = 0; openHigh = 0; openMedium = 0 }
    $spread  = $false
    $usb     = $false
    foreach ($weight in 'high', 'medium', 'low') {
        $label = switch ($weight) {
            'high'   { Get-Text 'Value.Stability.Weight.High' }
            'medium' { Get-Text 'Value.Stability.Weight.Medium' }
            default  { Get-Text 'Value.Stability.Weight.Low' }
        }
        foreach ($indicator in @($indicators | Where-Object { $_.Weight -eq $weight })) {
            if ($indicator.State -eq 'Present') {
                $present += Get-Text 'Value.Stability.Indicator.Weighted' $indicator.Text $label
                if ($weight -eq 'high')   { $count.high++ }
                if ($weight -eq 'medium') { $count.medium++ }
                if ($indicator.Key -eq 'FaultSiteSpread') { $spread = $true }
                if ($indicator.Key -eq 'UsbAdapter')      { $usb = $true }
            }
            elseif ($indicator.State -eq 'NotAssessed') {
                $open += Get-Text 'Value.Stability.Indicator.Weighted' $indicator.Name $label
                if ($weight -eq 'high')   { $count.openHigh++ }
                if ($weight -eq 'medium') { $count.openMedium++ }
            }
        }
    }

    $outcome = Get-VerdictOutcome -High $count.high -Medium $count.medium -Spread $spread `
        -FailHigh $failHigh -WarnHigh $warnHigh -SuspicionMedium $suspicionMedium

    # What it would have been had everything nobody could look at been there.
    $atMost = Get-VerdictOutcome -High ($count.high + $count.openHigh) -Medium ($count.medium + $count.openMedium) -Spread $spread `
        -FailHigh $failHigh -WarnHigh $warnHigh -SuspicionMedium $suspicionMedium
    $incomplete = $atMost -ne $outcome

    $severity = ''
    $hints    = @()
    $means    = @()
    switch ($outcome) {
        'Cause' {
            $severity = 'FAIL'
            $places   = Get-DataCollection $Network 'Places'
            if ($places.Count) { $sentence = Get-Text 'Value.Stability.Verdict.CauseShare' ($places -join ', ') }
            else               { $sentence = Get-Text 'Value.Stability.Verdict.Cause' }
            $means += Get-Text 'Meaning.Stability.VerdictNetworkCause'
            $hints += Get-StabilityNetworkHint -Kind Cause -Situation $Situation
        }
        'Suspicion' {
            $severity = 'WARN'
            $sentence = Get-Text 'Value.Stability.Verdict.Suspicion'
            $hints   += Get-StabilityNetworkHint -Kind Suspected -Situation $Situation
        }
        'Lead' {
            $severity = 'WARN'
            $sentence = Get-Text 'Value.Stability.Verdict.Lead'
            $hints   += Get-StabilityNetworkHint -Kind Suspected -Situation $Situation
        }
        'Environment' { $sentence = Get-Text 'Value.Stability.Verdict.Environment' }
        default       { $sentence = Get-Text 'Value.Stability.Verdict.Application' (Get-ProgramVendorText -App $App -Incident $Incident) }
    }
    if ($incomplete) { $sentence = Get-Text 'Value.Stability.Verdict.Incomplete' $sentence }

    # The dock is worth a look only where something points at the network at all.
    if ($usb -and $count.high -gt 0) { $hints += Get-StabilityUsbAdapterHint -Situation $Situation }

    [pscustomobject]@{
        Severity    = $severity
        Sentence    = $sentence
        Present     = [string[]]$present
        NotAssessed = [string[]]$open
        Outcome     = $outcome
        Incomplete  = $incomplete
        Hint        = [string[]]$hints
        Meaning     = [string[]]$means
    }
}

function Get-ProgramVerdictLine {
    <#
    .SYNOPSIS
        The lines a verdict adds to a program's Finding: the indicators that were present,
        the ones that could not be assessed, and the sentence. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Verdict)

    if (@($Verdict.Present).Count) { Get-Text 'Value.Stability.Verdict.Present' (@($Verdict.Present) -join '; ') }
    else                           { Get-Text 'Value.Stability.Verdict.PresentNone' }

    if (@($Verdict.NotAssessed).Count) { Get-Text 'Value.Stability.Verdict.NotAssessed' (@($Verdict.NotAssessed) -join '; ') }

    $Verdict.Sentence
}

function Test-ProgramNetworkConcern {
    <#
    .SYNOPSIS
        Whether what the network did around a program's crashes is worth a word in its
        Finding. Pure.
    .DESCRIPTION
        The question "did it crash because its Server dropped?" is one about a program
        that has a Server. Asked of every program, a monitoring agent installed on C:
        that throws NullReferenceException was told that the SMB event logs do not reach
        back far enough, to enlarge them, and that the evidence against it is incomplete
        - advice about a network it does not use, ahead of the advice that applies.
 
        So the network concerns a program when something says so:
 
          - it is not known to be off the network: it or a module of it is on a network
            path, a path of it is on a drive nobody could place, or nobody took the
            inventory that would have listed what it loads (the NetworkPath indicator
            is present, or not assessed)
          - an incident shows an exception that is what a lost share looks like
          - an incident did coincide with a disruption: what was found is always said
 
        Otherwise the correlation is set aside: no line, no Hint, and no open indicator
        that would call the verdict incomplete. The incidents' own table still shows a
        disruption beside each incident it was found for.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowNull()]$Network,
        [AllowNull()]$Correlation,
        [hashtable]$Parameters = @{}
    )

    if ("$(Get-DataProperty $Correlation 'State')" -eq 'Correlated') { return $true }
    $indicators = @(Get-ProgramCrashIndicator -Incident $Incident -Network $Network -Correlation $null -Adapters $null -Parameters $Parameters)
    # An indicator switched off in the Check Definition is not there, and says nothing.
    if (@($indicators | Where-Object { $_.Key -eq 'NetworkPath' -and $_.State -ne 'Absent' }).Count) { return $true }
    [bool]@($indicators | Where-Object { $_.Key -eq 'NetworkException' -and $_.State -eq 'Present' }).Count
}

function Get-ProgramCrashEvidence {
    <#
    .SYNOPSIS
        Everything the Judge knows about one program's crashes beyond the crashes: where
        it runs from, what the network did around them, and the verdict. Pure.
    .DESCRIPTION
        Read from this Kind's own data, into which the Gatherer copied what the network
        Checks before it gathered. Data from before it did carries none of that, and then
        reads as it should: nobody looked.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowEmptyString()][string]$App = '',
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        [AllowNull()]$Situation
    )

    $network     = Get-ProgramNetwork -App $App -Incident $Incident -NetworkDependency (Get-DataProperty $Data 'NetworkDependency')
    $correlation = Get-ProgramCorrelation -Incident $Incident -Network $network `
        -NetworkDisruption (Get-DataProperty $Data 'NetworkDisruption') -Parameters $Parameters
    if ($null -ne $correlation -and
        -not (Test-ProgramNetworkConcern -Incident $Incident -Network $network -Correlation $correlation -Parameters $Parameters)) {
        $correlation | Add-Member -NotePropertyName 'SetAside' -NotePropertyValue $true -Force
    }
    $verdict     = Get-ProgramCrashVerdict -App $App -Incident $Incident -Network $network -Correlation $correlation `
        -Adapters (Get-DataProperty $Data 'Adapters') -Parameters $Parameters -Situation $Situation

    [pscustomobject]@{
        Network     = $network
        Correlation = $correlation
        Verdict     = $verdict
    }
}

function Get-ProgramCrashLine {
    <#
    .SYNOPSIS
        What is known about one program's crashes, a line for each thing known. Pure.
    .DESCRIPTION
        How often and when last; where the program runs from, and that this is on the
        network when it is; the exception codes and types, counted; and over how many
        places the crashes are spread. A line is left out when nothing is known for it.
 
        A list, and not one sentence, because more is known than the crashes say: whether
        they coincided with a network disruption, and what all of it adds up to, are lines
        of their own that New-ProgramCrashFinding is handed. See Get-ProgramCrashEvidence.
        The lines are written to the pipeline one by one: collect them with @( ).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Incident,
        $Days = $script:StabilityDefaultDays,
        [hashtable]$Parameters = @{},

        # What Get-ProgramNetwork returned for the program, where a Check gathered what it
        # is built from. Without it a path is named as the event wrote it.
        [AllowNull()]$Network
    )

    $incidents = @($Incident | Where-Object { $null -ne $_ })
    if ($null -eq $Network) { $Network = Get-ProgramNetwork -Incident $incidents -NetworkDependency $null }
    $lines = @()

    $count = Get-Text 'Value.Stability.Program.Incidents' $incidents.Count $Days
    if ($incidents.Count -eq 1) { $count = Get-Text 'Value.Stability.Program.IncidentOne' $Days }
    $last = @($incidents | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } |
        Where-Object { $null -ne $_ } | Sort-Object -Descending)
    if ($last.Count) { $count = '{0}, {1}' -f $count, (Get-Text 'Value.Stability.Program.Last' $last[0]) }
    $lines += $count

    # A program can have been started from more than one place in a month. A mapped drive
    # is named as the share it stands for, because the share is what names the Server and
    # K: is a letter on one desk.
    foreach ($location in (Get-DataCollection $Network 'Locations')) {
        if ($location.Share -and $location.Drive) { $lines += Get-Text 'Value.Stability.Program.PathShare' $location.Share $location.Drive }
        elseif ($location.Remote)                 { $lines += Get-Text 'Value.Stability.Program.PathRemote' $location.Path }
        elseif ("$(Get-DataProperty $location 'Placement')" -eq 'Unknown') {
            # Neither mapped nor a disk of this machine: not said to be local.
            $lines += Get-Text 'Value.Stability.Program.PathUnknownDrive' $location.Path $location.Drive
        }
        else                                      { $lines += Get-Text 'Value.Stability.Program.Path' $location.Path }
    }

    $codes = Get-CountedText -Name @($incidents | ForEach-Object { Get-ExceptionCodeText (Get-DataProperty $_ 'Exception') })
    # By incident: a type the runtime named twice for one crash is one crash.
    $types = Get-CountedText -Name @($incidents | ForEach-Object {
        (Get-DataCollection $_ 'ExceptionTypes') | ForEach-Object { @("$_" -split '[.+]')[-1] } | Select-Object -Unique
    })
    $exceptions = @()
    if ($codes) { $exceptions += $codes }
    if ($types) { $exceptions += Get-Text 'Value.Stability.Program.DotNetTypes' $types }
    if ($exceptions.Count) { $lines += Get-Text 'Value.Stability.Program.Exceptions' ($exceptions -join '; ') }

    $sites = @(Get-FaultSite -Incident $incidents).Count
    if (Test-FaultSiteSpread -Incident $incidents -Parameters $Parameters) {
        $lines += Get-Text 'Value.Stability.Program.SitesOutside' $sites
    }
    elseif ($sites) { $lines += Get-Text 'Value.Stability.Program.Sites' $sites }

    $lines
}

# What stands between the lines of a Finding's Value. The Report shows a Value in one table
# cell and findings.csv in one field, so a line break would be lost in the one and awkward
# in the other.
$script:StabilityLineSeparator = ' | '

function New-ProgramCrashFinding {
    <#
    .SYNOPSIS
        The Finding for one program that crashes repeatedly. Pure.
    .DESCRIPTION
        Graded on the number of incidents: WARN from the third, because a program that
        gets this Finding has crashed repeatedly, and FAIL above twenty, which is where the
        count over all applications failed. Never OK, because it is a crash; below the
        WARN threshold - a Check Definition can ask for a Finding from the first crash -
        it is INFO.
 
        -Line, -Hint, -Meaning and -Severity are for what is known beyond the crashes -
        what the network did around them, and the verdict: those lines follow the ones
        built here, those Hints and Meanings lead, and that Severity counts when it is
        worse. Nothing makes the Finding better than the number of crashes makes it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowEmptyString()][string]$App = '',
        $Days = $script:StabilityDefaultDays,
        [hashtable]$Parameters = @{},
        [AllowNull()][AllowEmptyCollection()][string[]]$Line = @(),
        [AllowNull()][AllowEmptyCollection()][string[]]$Hint = @(),
        [AllowNull()][AllowEmptyCollection()][string[]]$Meaning = @(),
        [AllowEmptyString()][string]$Severity = '',

        # What Get-ProgramNetwork returned for the program. Built from the incidents alone
        # when it is left out, and then knows no drive mapping.
        [AllowNull()]$Network,

        # What is known of this program's crashes from looking at all programs together.
        [AllowNull()][AllowEmptyCollection()][string[]]$Signal,

        # What a Technician should read with it: the timeline, where the crashes began
        # after a Change.
        [AllowNull()][AllowEmptyCollection()]$Reference,

        # The application whose own Check judges this program in this Run. The crashes
        # are then counted here and no more: what they mean and what to do is said there.
        [AllowEmptyString()][string]$CoveredBy = ''
    )

    $warn = Get-NumberParameter $Parameters 'ProgramIncidentWarnAbove' 2
    $fail = Get-NumberParameter $Parameters 'ProgramIncidentFailAbove' 20

    $incidents = @($Incident | Where-Object { $null -ne $_ })
    if (-not $App -and $incidents.Count) { $App = "$(Get-DataProperty $incidents[0] 'App')".Trim() }
    if ($null -eq $Network) { $Network = Get-ProgramNetwork -App $App -Incident $incidents -NetworkDependency $null }

    if ($CoveredBy) {
        return New-Finding -Category Stability -Check (Get-Text 'Check.Stability.ProgramCrashes' $App) -Severity INFO `
            -Value ((@(Get-ProgramCrashLine -Incident $incidents -Days $Days -Parameters $Parameters -Network $Network)[0], (Get-Text 'Value.Stability.ProgramCovered' $CoveredBy)) -join $script:StabilityLineSeparator) `
            -Meaning (Get-Text 'Meaning.Stability.ProgramCovered' $CoveredBy) -Hint (Get-Text 'Hint.Stability.ProgramCovered' $CoveredBy) `
            -Signal @($Signal | Where-Object { $_ }) -Subject $App
    }

    $graded = Get-Severity $incidents.Count $warn $fail
    if ($graded -eq 'OK') { $graded = 'INFO' }

    $lines = @(Get-ProgramCrashLine -Incident $incidents -Days $Days -Parameters $Parameters -Network $Network) +
             @($Line | Where-Object { $_ })

    # What to do, the most specific first: what another Check added, that the program is
    # on the network, that the crashes are spread, where the exception types point - and
    # last where the evidence is, which is all there is to say when nothing else is known.
    #
    # What it means beside it, in the same order, where the lines do not say it: that
    # the crashes are spread, and what that speaks for, is a line of its own.
    $hints = @($Hint | Where-Object { $_ })
    $means = @($Meaning | Where-Object { $_ })
    if ([bool](Get-DataProperty $Network 'OnNetwork')) {
        $means += Get-Text 'Meaning.Stability.ProgramOnNetwork'
        $hints += Get-Text 'Hint.Stability.ProgramOnNetwork'
    }
    $drives = Get-DataCollection $Network 'UnknownDrives'
    if ($drives.Count) { $hints += Get-Text 'Hint.Stability.ProgramDriveUnknown' ($drives -join ', ') }
    if (Test-FaultSiteSpread -Incident $incidents -Parameters $Parameters) {
        $hints += Get-Text 'Hint.Stability.ProgramFaultSitesSpread'
    }
    $direction = Get-ExceptionTypeHint -RuntimeFault $incidents
    if ($direction) { $hints += $direction }
    $typeMeans = Get-ExceptionTypeMeaning -RuntimeFault $incidents
    if ($typeMeans) { $means += $typeMeans }
    $hints += Get-Text 'Hint.Stability.ProgramCrashes'

    # A page that could not be read from a share is the network; from a local drive it
    # is the drive.
    $signal = @($Signal | Where-Object { $_ })
    $unread = @($incidents | Where-Object { "$(Get-DataProperty $_ 'Exception')" -match '(?i)c0000006' })
    # Known to be local, and not merely not known to be on the network: a drive nobody
    # could place is no evidence about the drives of this machine.
    if ($unread.Count -and [bool](Get-DataProperty $Network 'KnownLocal')) { $signal += 'program-not-paged-in' }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.ProgramCrashes' $App) `
        -Severity (Get-WorstSeverity $graded $Severity) `
        -Value ($lines -join $script:StabilityLineSeparator) `
        -Meaning ($means -join $script:StabilityLineSeparator) `
        -Hint ($hints -join $script:StabilityLineSeparator) `
        -Signal $signal -Subject $App -Reference @($Reference | Where-Object { $null -ne $_ })
}

function New-ScatteredCrashFinding {
    <#
    .SYNOPSIS
        One Finding for the programs that crashed, but not often enough for one of their
        own. Nothing when there is no such program. Pure.
    .DESCRIPTION
        Graded as the count over all applications was, on the incidents left over: many
        programs each crashing a little is still a machine on which programs crash. Below
        the WARN threshold it is INFO, not OK, because it reports crashes.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()]$Program, [hashtable]$Parameters = @{}, [AllowNull()][AllowEmptyCollection()][string[]]$Signal)

    $programs = @($Program | Where-Object { $null -ne $_ })
    if (-not $programs.Count) { return }

    $warn = Get-NumberParameter $Parameters 'ApplicationCrashWarnAbove' 5
    $fail = Get-NumberParameter $Parameters 'ApplicationCrashFailAbove' 20

    $names = @(foreach ($program in $programs) {
        $name = $program.App
        if (-not $name) { $name = Get-Text 'Value.Stability.UnknownProgram' }
        foreach ($incident in @($program.Incidents)) { $name }
    })

    $severity = Get-Severity $names.Count $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.ScatteredCrashes') -Severity $severity `
        -Value (Get-Text 'Value.Stability.Scattered' $names.Count (Get-CountedText -Name $names -First 5)) `
        -Hint (Get-Text 'Hint.Stability.CrashingApplicationsSeeTheSection') -Signal @($Signal | Where-Object { $_ })
}