Private/Lead.ps1

# Leads: what several Findings point to together.
#
# A blue screen from a read error, eleven disk errors and a drive that reports wear are
# three Findings in three places, and seeing that they are one story is the step that
# takes experience. Once every Check has run, a Run reads its Findings together and says
# what several of them point to. A Lead is not a Finding: it observes nothing, has no
# Severity and is not counted. See ADR-0005.
#
# A rule is a name and the Signals that support it. It goes by Signals and never by what
# a Finding says, so that rewording a Finding cannot stop a Lead from forming.

$script:LeadRules = @(
    [pscustomobject]@{ Name = 'drive'; Signals = @('disk-errors', 'drive-health-warning', 'stop-code-disk', 'disk-write-slow', 'program-not-paged-in') }
    [pscustomobject]@{ Name = 'memory'; Signals = @('stop-code-memory', 'hardware-errors', 'access-violations-spread') }
    [pscustomobject]@{ Name = 'graphics'; Signals = @('live-kernel-graphics', 'stop-code-graphics', 'graphics-driver-old') }
    # Several Servers affected is this machine's connection; one Server affected is that
    # Server. The two go by different Signals, so that neither is taken for the other.
    [pscustomobject]@{ Name = 'connection'; Signals = @('link-dropped', 'servers-lost', 'loss-on-own-network', 'usb-network-adapter', 'crash-with-network-disruption') }
    # One Lead for each Server: its Findings are read together by what they are about.
    [pscustomobject]@{ Name = 'server'; PerSubject = $true; Signals = @('server-lost', 'server-slow', 'server-unreachable', 'dependency-on-server') }
)

# How many Leads a Report shows. More than this at the top of a Report is a list again,
# and a list is what a Lead is there to spare the reader.
$script:LeadMaximum = 3

function Get-LeadText {
    <#
    .SYNOPSIS
        What a Lead is called and what to do about it. A rule this has no words for is
        called by its name, which shows that the words are missing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Name, [AllowNull()][AllowEmptyString()][string]$Subject, [AllowNull()]$Situation)

    switch ($Name) {
        'connection' {
            return [pscustomobject]@{ Title = Get-Text 'Lead.Connection.Title'
                Hint = (Get-Text 'Lead.Connection.Hint') -f (Get-SituationLinkText -Situation $Situation) }
        }
        'server' {
            return [pscustomobject]@{ Title = (Get-Text 'Lead.Server.Title') -f $Subject; Hint = (Get-Text 'Lead.Server.Hint') -f $Subject }
        }
        'drive'    { return [pscustomobject]@{ Title = Get-Text 'Lead.Drive.Title';    Hint = Get-Text 'Lead.Drive.Hint' } }
        'memory'   { return [pscustomobject]@{ Title = Get-Text 'Lead.Memory.Title';   Hint = Get-Text 'Lead.Memory.Hint' } }
        'graphics' { return [pscustomobject]@{ Title = Get-Text 'Lead.Graphics.Title'; Hint = Get-Text 'Lead.Graphics.Hint' } }
    }
    [pscustomobject]@{ Title = $Name; Hint = '' }
}

function ConvertTo-LeadSubject {
    <#
    .SYNOPSIS
        What a Finding is about, as something two Findings can be compared by. Pure.
    .DESCRIPTION
        One Check names a Server FS01 and another fs01.contoso-example.de. A name is taken
        by its first label, in lower case; an address is taken as it is, because its
        first part is not a name for it.
    #>

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

    $text = "$Subject".Trim().ToLowerInvariant()
    if (-not $text) { return '' }
    if ($text -match '^\d{1,3}(\.\d{1,3}){3}$' -or $text.Contains(':')) { return $text }
    ($text -split '\.')[0]
}

function Get-Lead {
    <#
    .SYNOPSIS
        The Leads a Run's Findings support. Pure.
    .DESCRIPTION
        A Lead takes two Findings that observed two different things its rule names. One
        Finding has its own Meaning and Hint and needs no reading-together. The same thing
        observed twice - two drives that report wear, two Servers that are slow - is two
        Findings about two things, and no evidence that either is what the other means.
 
        A Finding that is in order observed nothing that supports anything.
 
        Every rule that fits is stated, the one resting on more Findings first, and no
        more than a few: a Report that showed one of two would claim to know which it is.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [AllowNull()][AllowEmptyCollection()]$Rule = $script:LeadRules,

        # What a Lead's Hint may assume about the machine, like any Hint.
        [AllowNull()]$Situation
    )

    $all        = @($Finding | Where-Object { $_ })
    $candidates = @($all | Where-Object { "$(Get-DataProperty $_ 'Severity')" -ne 'OK' })

    $formed = New-Object 'System.Collections.Generic.List[object]'
    $open   = New-Object 'System.Collections.Generic.List[object]'
    $order  = 0
    foreach ($one in @($Rule | Where-Object { $_ })) {
        $order++
        $wanted = @((Get-DataCollection $one 'Signals') | ForEach-Object { "$_" })
        $mine   = @($candidates | Where-Object { @((Get-DataCollection $_ 'Signals') | Where-Object { $_ -in $wanted }).Count })
        if (-not $mine.Count) { continue }

        # A rule about one thing among several is asked once for each of them, and a
        # Finding that is about nothing in particular takes part in none.
        $groups = @([pscustomobject]@{ Subject = ''; Findings = $mine })
        if ([bool](Get-DataProperty $one 'PerSubject')) {
            $groups = @($mine | Where-Object { ConvertTo-LeadSubject "$(Get-DataProperty $_ 'Subject')" } |
                Group-Object { ConvertTo-LeadSubject "$(Get-DataProperty $_ 'Subject')" } | Sort-Object Name | ForEach-Object {
                    [pscustomobject]@{ Subject = "$(Get-DataProperty $_.Group[0] 'Subject')"; Findings = @($_.Group) } })
        }

        foreach ($group in $groups) {
            $support = @($group.Findings)
            $seen = @{}
            foreach ($f in $support) { foreach ($signal in @((Get-DataCollection $f 'Signals') | Where-Object { $_ -in $wanted })) { $seen["$signal"] = $true } }

            # What could have supported it and did not look: a Check that was skipped,
            # rights that were not given. Its Value says why. It supports nothing. Of a
            # rule about one thing, only what is about that thing or about none.
            $key = ConvertTo-LeadSubject $group.Subject
            $notChecked = @(foreach ($f in $all) {
                if (-not @((Get-DataCollection $f 'Unobserved') | Where-Object { $_ -in $wanted }).Count) { continue }
                $about = ConvertTo-LeadSubject "$(Get-DataProperty $f 'Subject')"
                if ($key -and $about -and $about -ne $key) { continue }
                [pscustomobject]@{ Check = "$(Get-DataProperty $f 'Check')"; Reason = "$(Get-DataProperty $f 'Value')" }
            })

            $isLead = $support.Count -ge 2 -and $seen.Count -ge 2
            # One observation and nobody looked for the second: not a Lead, and not nothing.
            if (-not $isLead -and -not $notChecked.Count) { continue }

            $text = Get-LeadText -Name "$(Get-DataProperty $one 'Name')" -Subject $group.Subject -Situation $Situation
            $lead = [pscustomobject]@{
                PSTypeName = 'Gutcheck.Lead'
                Name       = "$(Get-DataProperty $one 'Name')"
                Subject    = "$($group.Subject)"
                Title      = $text.Title
                Hint       = $text.Hint
                Findings   = $support
                Signals    = @($seen.Keys | Sort-Object)
                NotChecked = $notChecked
                # $false: what was observed is one thing only, and what could have made a
                # Lead of it was not looked at. Said under the Leads, so that it is not
                # read as none.
                Formed     = $isLead
                Order      = $order
            }
            if ($isLead) { $formed.Add($lead) } else { $open.Add($lead) }
        }
    }
    # Sort-Object is not stable on Windows PowerShell 5.1, so the order rules were given
    # in is part of the key.
    @($formed | Sort-Object @{ Expression = { $_.Findings.Count }; Descending = $true }, Order | Select-Object -First $script:LeadMaximum)
    @($open | Sort-Object Order | Select-Object -First $script:LeadMaximum)
}