Private/Scope.ps1

# A Scope: a named part of the Checks, for a Run that is to look at one thing.
#
# A Run performs every Check of the Check Definitions, and that is what a Run is when
# nobody says otherwise. A Technician with a complaint about the network does not need
# the stress test, and one who is asked whether the disk is failing does not need the
# sign-in of the last month. A Scope names the Checks for such a question, and a Run
# that is given one performs those and no others.
#
# Scopes are data, in the Check Definition document beside the Checks (ADR-0001):
#
# "Scopes": [ { "Name": "Hardware", what -Scope takes
# "Title": "Hardware", what the Technician reads
# "Checks": [ "System", "Memory" ], the Checks, by their Name
# "Apps": false }, whether the applications take part
# { "Name": "Quick", "Title": "Schnell", "Except": [ "CPU stress test" ] },
# { "Name": "Web", "Checks": [ "System" ], "Kinds": [ "Website" ] } ]
#
# "Checks" names what is in; "Except" names what is out of everything; "Kinds" takes in
# every Check of those Kinds, whatever its name - the website Checks a Customer's
# Definitions list, which no Scope in the Local Definitions can name. Apps is true where
# it is not said. Several Scopes at once are all their Checks together.
#
# A Check that is left out leaves no Finding of its own - thirty lines of "not performed"
# would bury the Report. One Finding says what the Scope was and what it left out, and
# carries as Unobserved every Signal the omitted Checks would have looked for, so that a
# Lead says "not checked" and never "nothing found" (ADR-0005).
#
# Some Checks read what another gathered, and judge differently without it. A Scope that
# holds the reader and not its source is a mistake in the Definitions; it is found by a
# test over the Local Definitions, and said in the Finding for any others.

# Which Kinds a Kind reads, where going without changes what it says and it does not say
# so, or where one of a pair is no use without the other. Not every reading: a Kind that
# reads another only to save a second look is not here.
$script:ScopeReadsFrom = @{
    Program                   = @('ProgramElevated')
    ProgramElevated           = @('Program')
    Session                   = @('SessionElevated')
    SessionElevated           = @('Session')
    UserProfile               = @('UserProfileElevated', 'Storage', 'NetworkDependency')
    UserProfileElevated       = @('UserProfile')
    NetworkDependency         = @('NetworkDependencyElevated')
    NetworkDependencyElevated = @('NetworkDependency')
    Network                   = @('NetworkDisruption')
    Logon                     = @('NetworkDisruption')
    Stability                 = @('NetworkDisruption', 'DiskHealth')
    AppRuntime                = @('DotNet')
    App                       = @('Stability')
    Service                   = @('Stability')
    Companion                 = @('Stability')
    # The proxy a page went through, held against the User's, WinHTTP's and a policy's.
    Website                   = @('Network', 'Browser')
    # The browsers' crashes are held against those the Stability Check read. Not Network: the
    # proxy a policy forces is compared with the Network Check's, and where that did not run
    # the Finding says what it could not compare - the Scope Apps runs without it.
    Browser                   = @('Stability')
}

function ConvertTo-ScopeDefinition {
    <#
    .SYNOPSIS
        One entry of a document's Scopes as a Run uses it. Nothing for an entry without
        a name. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Entry)

    $name = "$(Get-DataProperty $Entry 'Name')".Trim()
    if (-not $name) { return }
    $title = "$(Get-DataProperty $Entry 'Title')".Trim()
    if (-not $title) { $title = $name }
    $apps = Get-DataProperty $Entry 'Apps'
    [pscustomobject]@{
        PSTypeName = 'Gutcheck.ScopeDefinition'
        Name       = $name
        Title      = $title
        Checks     = [string[]]@(@(Get-DataProperty $Entry 'Checks') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
        Except     = [string[]]@(@(Get-DataProperty $Entry 'Except') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
        Kinds      = [string[]]@(@(Get-DataProperty $Entry 'Kinds') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
        Apps       = $(if ($null -eq $apps) { $true } else { [bool]$apps })
    }
}

function Select-ScopeCheck {
    <#
    .SYNOPSIS
        Narrows the Check Definitions of a Run to the Scopes it was given. Pure.
    .DESCRIPTION
        Without a Scope everything comes back as it went in. With one or several: the
        Checks about the machine that any of them names, in the order of the document.
        The Checks of the applications stay where any of the Scopes takes applications,
        or the Technician asked for applications by name; which of them run is then the
        application chooser's to say, as in any Run.
 
        Returns what to perform, what was left out, and what is wrong with the Scopes:
        a name no Scope has, a Check no Definition has, and a Check that reads another
        which is not among them.
    .PARAMETER KeepApps
        The Technician named applications: they take part whatever the Scope says.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Definition,
        [AllowNull()][AllowEmptyCollection()]$Scope,
        [AllowNull()][AllowEmptyCollection()][string[]]$Requested = @(),
        [switch]$KeepApps
    )

    $all       = @($Definition | Where-Object { $_ })
    $known     = @($Scope | Where-Object { $_ })
    $asked     = @($Requested | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    $chosen    = @($known | Where-Object { $asked -contains $_.Name })
    $unknown   = @($asked | Where-Object { $name = $_; -not @($known | Where-Object { $_.Name -eq $name }).Count })

    if (-not $chosen.Count) {
        return [pscustomobject]@{
            PSTypeName = 'Gutcheck.ScopeSelection'
            Scope = @(); Check = $all; Omitted = @(); OmittedApps = @(); Apps = $true
            UnknownScope = $unknown; UnknownCheck = @(); Unmet = @()
        }
    }

    $isApp   = { param($One) [bool](Get-DefinitionApp -Definition $One) -or "$(Get-DataProperty $One 'Kind')" -eq 'App' }
    $machine = @($all | Where-Object { -not (& $isApp $_) })
    $names   = @($machine | ForEach-Object { "$(Get-DataProperty $_ 'Name')" })

    $wanted = [System.Collections.Generic.List[string]]::new()
    foreach ($one in $chosen) {
        $take = @($one.Checks)
        # "Except", or neither: everything about the machine but what is named.
        if (-not $take.Count -and -not @($one.Kinds).Count) { $take = @($names | Where-Object { $one.Except -notcontains $_ }) }
        $take += @($machine | Where-Object { @($one.Kinds) -contains "$(Get-DataProperty $_ 'Kind')" } | ForEach-Object { "$(Get-DataProperty $_ 'Name')" })
        foreach ($name in $take) { if (-not $wanted.Contains($name)) { $wanted.Add($name) } }
    }
    $unknownCheck = @($chosen | ForEach-Object { @($_.Checks) + @($_.Except) } | Where-Object { $names -notcontains $_ } | Select-Object -Unique)

    $apps    = [bool]$KeepApps -or [bool]@($chosen | Where-Object { $_.Apps }).Count
    $kept    = @($all | Where-Object { if (& $isApp $_) { $apps } else { $wanted -contains "$(Get-DataProperty $_ 'Name')" } })
    $omitted = @($machine | Where-Object { $wanted -notcontains "$(Get-DataProperty $_ 'Name')" })

    # A reader among the Checks whose source is not.
    $kinds = @($kept | ForEach-Object { "$(Get-DataProperty $_ 'Kind')" } | Select-Object -Unique)
    $unmet = @(foreach ($kind in $kinds) {
        foreach ($source in @($script:ScopeReadsFrom[$kind])) {
            if ($source -and $kinds -notcontains $source) {
                # Only where the document has such a Check at all: what it does not carry
                # no Scope can be blamed for leaving out.
                $has = @($all | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq $source })[0]
                $reader = @($kept | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq $kind })[0]
                if ($has) { [pscustomobject]@{ Reader = "$(Get-DataProperty $reader 'Name')"; Source = "$(Get-DataProperty $has 'Name')" } }
            }
        }
    })

    [pscustomobject]@{
        PSTypeName   = 'Gutcheck.ScopeSelection'
        Scope        = $chosen
        Check        = $kept
        Omitted      = $omitted
        # The Checks of the applications, where the Scope takes none: left out too, and
        # they look for things of their own - whether a Server answers, above all.
        OmittedApps  = @($all | Where-Object { (& $isApp $_) -and -not $apps })
        Apps         = $apps
        UnknownScope = $unknown
        UnknownCheck = $unknownCheck
        Unmet        = $unmet
    }
}

function Read-ScopeSelection {
    <#
    .SYNOPSIS
        Asks the Technician what the Run is to look at: everything, or one of the Scopes.
        Returns the names chosen - none for everything.
    .DESCRIPTION
        Everything is where the pointer starts, so Enter is the Run it always was, and
        that is what comes back where nobody can be asked. The asking is Lantern's.
    #>

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

    $known = @($Scope | Where-Object { $_ })
    if (-not $known.Count) { return @() }

    $items = @([pscustomobject]@{ Name = ''; Title = (Get-Text 'Console.Scope.Everything'); Detail = '' }) + @($known | ForEach-Object {
        [pscustomobject]@{ Name = $_.Name; Title = $_.Title; Detail = "$($_.Name)" } })
    $chosen = Read-LanternChoice (Get-Text 'Console.Scope.Heading') -Item $items -Label { $_.Title } -Detail { $_.Detail } -Default { -not $_.Name }
    if ($null -eq $chosen -or -not $chosen.Name) { return @() }
    @($chosen.Name)
}

function Resolve-ScopeCheck {
    <#
    .SYNOPSIS
        The Checks of this Run by the Scope it was given, or by the one the Technician
        chooses where none was given and somebody is there to ask.
    .DESCRIPTION
        A name no Scope has stops the Run: a mistyped Scope must not become a Run of
        everything that somebody then takes for the narrow one.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Definition,
        [AllowNull()][AllowEmptyCollection()]$Scope,
        [AllowNull()][AllowEmptyCollection()][string[]]$Requested = @(),
        [switch]$KeepApps,

        # The Website Checks of the addresses named with -Url (ConvertTo-WebsiteUrlCheck).
        # They are performed whatever the Scope: they are what the Technician asked for.
        [AllowNull()][AllowEmptyCollection()]$Website = @()
    )

    $sites = @($Website | Where-Object { $_ })
    $asked = @($Requested | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    # An address and no Scope is a question about a website: the Scope "Web" answers it,
    # and asking which Scope would only be in the way. Where the Definitions have no such
    # Scope - older ones - the Run is everything, as it would have been.
    if (-not $asked.Count -and $sites.Count) {
        if (@($Scope | Where-Object { $_ -and $_.Name -eq 'Web' }).Count) { $asked = @('Web') }
    }
    elseif (-not $asked.Count -and @($Scope).Count -and (Test-RunInteractive)) {
        $asked = @(Read-ScopeSelection -Scope $Scope)
    }

    $selection = Select-ScopeCheck -Definition $Definition -Scope $Scope -Requested $asked -KeepApps:$KeepApps
    if (@($selection.UnknownScope).Count) {
        $names = @($Scope | Where-Object { $_ } | ForEach-Object { $_.Name }) -join ', '
        if (-not $names) { $names = Get-Text 'Value.Shared.None' }
        throw ((Get-Text 'Error.Scope.Unknown') -f (@($selection.UnknownScope) -join ', '), $names)
    }
    # A Scope for websites with no website to load - none in the Definitions, none named
    # with -Url - would run the Checks around them and load nothing. Somebody at the window
    # is asked for the addresses, as for the Scope; where nobody names one, the Run says so
    # (New-WebsiteNotGivenFinding), so that the gap is not taken for a clean result.
    $forSites = @(@($selection.Scope) | Where-Object { @(Get-DataProperty $_ 'Kinds') -contains 'Website' }).Count -gt 0
    $listed   = @($selection.Check | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq 'Website' }).Count -gt 0
    if ($forSites -and -not $listed -and -not $sites.Count -and (Test-RunInteractive)) {
        $sites = @(Read-WebsiteUrlCheck | Where-Object { $_ })
    }
    $selection | Add-Member -NotePropertyName WebsiteMissing -NotePropertyValue ([bool]($forSites -and -not $listed -and -not $sites.Count)) -Force

    # The website Checks last, those a Definition lists as well as those of -Url: they read
    # what the network and the browsers gathered, so they come after those Checks wherever
    # the Definitions put them.
    $all = @(@($selection.Check) + $sites | Where-Object { $_ })
    $selection.Check = @(@($all | Where-Object { "$(Get-DataProperty $_ 'Kind')" -ne 'Website' }) +
                         @($all | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq 'Website' }))
    $selection
}

function Get-ScopeSuggestion {
    <#
    .SYNOPSIS
        The Scope to run again in, so that a set of Checks looks together: the one with
        the fewest Checks that holds every one named, or a Run without a Scope where none
        does. Nothing where no Check is named. Pure.
    .DESCRIPTION
        Asked for a suspicion that could not form because the Scope left out what would
        have formed it. What was seen has to be looked at again in the same Run as what
        was not, or the two never meet: both are named.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Definition,
        [AllowNull()][AllowEmptyCollection()]$Scope,
        [AllowNull()][AllowEmptyCollection()][string[]]$Check = @()
    )

    $needed = @($Check | ForEach-Object { "$_".Trim() } | Where-Object { $_ } | Select-Object -Unique)
    if (-not $needed.Count) { return }
    $all = @($Definition | Where-Object { $_ })

    $best = $null
    foreach ($one in @($Scope | Where-Object { $_ })) {
        $holds = @((Select-ScopeCheck -Definition $all -Scope $Scope -Requested @($one.Name)).Check | ForEach-Object { "$(Get-DataProperty $_ 'Name')" })
        if (@($needed | Where-Object { $holds -notcontains $_ }).Count) { continue }
        if ($null -eq $best -or $holds.Count -lt $best.Count) {
            $best = [pscustomobject]@{ Name = $one.Name; Title = $one.Title; Count = $holds.Count }
        }
    }
    if ($best) { return $best }
    [pscustomobject]@{ Name = ''; Title = ''; Count = $all.Count }
}

function Get-ScopeTitle {
    <#
    .SYNOPSIS
        What a Run's Scope is called in the Report and in the window: the titles of the
        Scopes it was given, or nothing for a Run of everything. Pure.
    #>

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

    @((Get-DataCollection $Selection 'Scope') | ForEach-Object { "$(Get-DataProperty $_ 'Title')" } | Where-Object { $_ }) -join ', '
}

function New-ScopeFinding {
    <#
    .SYNOPSIS
        The Finding that says a Run was narrowed: to what, and what it therefore did not
        look at. Nothing for a Run of everything. Pure.
    .DESCRIPTION
        Its Unobserved are the Signals of every Kind that was left out, so that whatever
        reads Findings together knows of each that nobody looked.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Selection)

    $title = Get-ScopeTitle -Selection $Selection
    if (-not $title) { return }

    $omitted = @((Get-DataCollection $Selection 'Omitted') | ForEach-Object { $_ })
    $value   = @((Get-Text 'Value.Scope.Chosen') -f $title, @((Get-DataCollection $Selection 'Check') | ForEach-Object { $_ }).Count, $omitted.Count)
    if ($omitted.Count) { $value += (Get-Text 'Value.Scope.Omitted') -f (@($omitted | ForEach-Object { "$(Get-DataProperty $_ 'Name')" }) -join '; ') }
    if (-not [bool](Get-DataProperty $Selection 'Apps')) { $value += Get-Text 'Value.Scope.NoApps' }

    $hint = @(Get-Text 'Hint.Scope.Chosen')
    $unmet = @((Get-DataCollection $Selection 'Unmet') | ForEach-Object { $_ })
    if ($unmet.Count) {
        $value += (Get-Text 'Value.Scope.Unmet') -f (@($unmet | ForEach-Object { (Get-Text 'Value.Scope.UnmetItem') -f "$($_.Reader)", "$($_.Source)" }) -join '; ')
        $hint  += Get-Text 'Hint.Scope.Unmet'
    }
    $strange = @((Get-DataCollection $Selection 'UnknownCheck') | ForEach-Object { "$_" } | Where-Object { $_ })
    if ($strange.Count) {
        $value += (Get-Text 'Value.Scope.UnknownCheck') -f ($strange -join '; ')
        $hint  += Get-Text 'Hint.Scope.UnknownCheck'
    }

    # Which Check would have looked for what: so that a Lead names the one or two that
    # matter to it, and not the whole list of what a Scope left out.
    $reason = (Get-Text 'Value.Scope.NotInScope') -f $title
    $by = @($omitted | ForEach-Object {
        $mine = @(Get-SignalOfKind -Kind "$(Get-DataProperty $_ 'Kind')" | Where-Object { $_ })
        if ($mine.Count) { [pscustomobject]@{ Check = "$(Get-DataProperty $_ 'Name')"; Signals = $mine; Reason = $reason } }
    })
    $appSignals = @((Get-DataCollection $Selection 'OmittedApps') | ForEach-Object { Get-SignalOfKind -Kind "$(Get-DataProperty $_ 'Kind')" } |
        Where-Object { $_ } | Select-Object -Unique)
    if ($appSignals.Count) { $by += [pscustomobject]@{ Check = (Get-Text 'Value.Scope.Apps'); Signals = $appSignals; Reason = $reason } }

    $signals = @($by | ForEach-Object { $_.Signals } | Select-Object -Unique)
    $finding = New-Finding -Category Gutcheck -Check (Get-Text 'Check.Scope.Scope') -Severity INFO -Value ($value -join ' | ') `
        -Meaning (Get-Text 'Meaning.Scope.Chosen') -Hint ($hint -join ' | ') -Unobserved $signals
    $finding | Add-Member -NotePropertyName UnobservedBy -NotePropertyValue $by -PassThru
}