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" ] } ]
#
# "Checks" names what is in; "Except" names what is out of everything. 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')
}

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 { $_ })
        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 = @(); 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) { $take = @($names | Where-Object { $one.Except -notcontains $_ }) }
        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
        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
    )

    $asked = @($Requested | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    if (-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)
    }
    $selection
}

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'
    }

    $signals = @($omitted | ForEach-Object { Get-SignalOfKind -Kind "$(Get-DataProperty $_ 'Kind')" } | Where-Object { $_ } | Select-Object -Unique)
    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
}