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') }
    # Of the hardware errors Windows logs, only those of the memory: a processor or a bus
    # error beside a memory stop code is no second sign of the memory.
    [pscustomobject]@{ Name = 'memory'; Signals = @('stop-code-memory', 'memory-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.
    #
    # Background is a Signal that is how most machines are and no observation of anything
    # going wrong: power saving on the adapter is what Windows sets. It supports a Lead
    # beside something that was observed, from a Finding that is in order too - and by
    # itself it starts nothing, not even "no Lead possible": that stood in every Report
    # of a Run that left Checks out, about a machine with nothing wrong.
    [pscustomobject]@{ Name = 'connection'; Signals = @('link-dropped', 'servers-lost', 'cable-wifi-switching', 'loss-on-own-network', 'usb-network-adapter', 'adapter-power-saving', 'crash-with-network-disruption')
        Background = @('adapter-power-saving') }
    # 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') }
    # Drives mapped by the domain's name, and one of the domain controllers behind it
    # that takes long: the drives come late on some days, and three Checks each say a
    # part of why. Seen on a desktop whose sign-in took ninety seconds every second day.
    [pscustomobject]@{ Name = 'domain-drives'; Signals = @('drive-mapping-slow', 'share-not-found', 'sign-in-intermittent', 'sign-in-by-controller', 'controller-differs', 'namespace-target-gone', 'policies-differ') }
)

# 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,
        # The Signals the Lead rests on. A rule says the same of whatever supports it,
        # but for one case: a connection that keeps failing over from cable to Wi-Fi and
        # back is said as that, because what to do about it is not what to do about any
        # connection that drops.
        [AllowNull()][AllowEmptyCollection()][string[]]$Signal
    )

    switch ($Name) {
        'connection' {
            if (@($Signal) -contains 'cable-wifi-switching') {
                return [pscustomobject]@{ Title = Get-Text 'Lead.Connection.Switching.Title'; Hint = Get-Text 'Lead.Connection.Switching.Hint' }
            }
            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 }
        }
        'domain-drives' {
            # Which controller it is, is known where the sign-ins part by it.
            $hint = Get-Text 'Lead.DomainDrives.Hint'
            if (@($Signal) -contains 'sign-in-by-controller') { $hint = '{0} | {1}' -f (Get-Text 'Lead.DomainDrives.Hint.ByController'), $hint }
            # A cause that was found is said in front of everything that is only to be checked.
            if (@($Signal) -contains 'policies-differ')       { $hint = '{0} | {1}' -f (Get-Text 'Lead.DomainDrives.Hint.Policies'), $hint }
            if (@($Signal) -contains 'namespace-target-gone') { $hint = '{0} | {1}' -f (Get-Text 'Lead.DomainDrives.Hint.Namespace'), $hint }
            return [pscustomobject]@{ Title = Get-Text 'Lead.DomainDrives.Title'; Hint = $hint }
        }
        '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 - but for what
        a rule calls Background: how the machine is set, which is no fault and still
        counts beside one. Background alone starts nothing.
 
        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 { "$_" })
        $background = @((Get-DataCollection $one 'Background') | ForEach-Object { "$_" })
        # What observed something: not in order, and about more than how the machine is set.
        $mine   = @($candidates | Where-Object { @((Get-DataCollection $_ 'Signals') | Where-Object { $_ -in $wanted -and $_ -notin $background }).Count })
        if (-not $mine.Count) { continue }
        if ($background.Count) {
            $mine += @($all | Where-Object { $mine -notcontains $_ -and @((Get-DataCollection $_ 'Signals') | Where-Object { $_ -in $background }).Count })
        }

        # 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 }
                # A Finding that speaks for several Checks - a Scope that left thirty out -
                # says which of them would have looked for this, and only those are named.
                $by = @((Get-DataCollection $f 'UnobservedBy') | ForEach-Object { $_ })
                if ($by.Count) {
                    foreach ($item in $by) {
                        if (@((Get-DataCollection $item 'Signals') | Where-Object { $_ -in $wanted }).Count) {
                            [pscustomobject]@{ Check = "$(Get-DataProperty $item 'Check')"; Reason = "$(Get-DataProperty $item 'Reason')" }
                        }
                    }
                    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 -Signal @($seen.Keys)

            # When what it rests on was last seen: only where every Finding it rests on
            # says so - one that is about now would make any date a wrong one. A fault
            # that was repaired last week still fills the month the Findings read, and
            # without this a repaired machine reads as broken.
            $dates = @($support | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'LastSeen') })
            $lastSeen = $null
            if ($dates.Count -and -not @($dates | Where-Object { $null -eq $_ }).Count) {
                $lastSeen = @($dates | Sort-Object)[-1]
                $text.Hint = '{0} | {1}' -f ((Get-Text 'Lead.LastSeen') -f $lastSeen), $text.Hint
            }
            $lead = [pscustomobject]@{
                PSTypeName = 'Gutcheck.Lead'
                Name       = "$(Get-DataProperty $one 'Name')"
                Subject    = "$($group.Subject)"
                Title      = $text.Title
                Hint       = $text.Hint
                Findings   = $support
                LastSeen   = $lastSeen
                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)
}