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')
    DomainAccount             = @('DomainAccountElevated')
    DomainAccountElevated     = @('DomainAccount')
    UserProfile               = @('UserProfileElevated', 'Storage', 'NetworkDependency')
    UserProfileElevated       = @('UserProfile')
    NetworkDependency         = @('NetworkDependencyElevated')
    NetworkDependencyElevated = @('NetworkDependency')
    Network                   = @('NetworkDisruption')
    Logon                     = @('NetworkDisruption')
    Stability                 = @('NetworkDisruption', 'DiskHealth')
    AppRuntime                = @('DotNet')
    App                       = @('Stability', 'ProgramElevated')
    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 })
        # Whether this Scope also performs the integrity scan, which no Check Definition
        # holds (New-IntegrityCheckDefinition): as -RunIntegrityCheck adds it. A document
        # without it, and a module older than it, perform the Scope without the scan.
        Integrity  = [bool](Get-DataProperty $Entry 'Integrity')
    }
}

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

    $isApp   = { param($One) [bool](Get-DefinitionApp -Definition $One) -or "$(Get-DataProperty $One 'Kind')" -eq 'App' }
    $appChecks = @($all | Where-Object { & $isApp $_ })
    if (-not $chosen.Count) {
        return [pscustomobject]@{
            PSTypeName = 'Gutcheck.ScopeSelection'
            Scope = @(); Check = $all; Omitted = @(); OmittedApps = @(); Apps = $true; AppsByScope = $true; AppCheck = $appChecks
            UnknownScope = $unknown; UnknownCheck = @(); Unmet = @()
        }
    }

    $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)

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

    $unmet = @(Get-ScopeUnmet -Check $kept -Definition $all)

    [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
        # Whether a Scope chosen takes applications by itself, and which Checks are the
        # applications': what Complete-ScopeAppSelection settles a Scope without them by.
        AppsByScope  = $byScope
        AppCheck     = $appChecks
        UnknownScope = $unknown
        UnknownCheck = $unknownCheck
        Unmet        = $unmet
    }
}

function Get-ScopeUnmet {
    <#
    .SYNOPSIS
        Each reader among the Checks performed whose source is not among them, by the
        names of both. Pure.
    .PARAMETER Check
        The Checks the Run performs.
    .PARAMETER Definition
        Every Check the document has: only a source the document carries at all can be
        blamed on the Scope for leaving it out.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Check, [AllowNull()][AllowEmptyCollection()]$Definition)

    $kept  = @($Check | Where-Object { $_ })
    $all   = @($Definition | Where-Object { $_ })
    $kinds = @($kept | ForEach-Object { "$(Get-DataProperty $_ 'Kind')" } | Select-Object -Unique)
    foreach ($kind in $kinds) {
        foreach ($source in @($script:ScopeReadsFrom[$kind])) {
            if ($source -and $kinds -notcontains $source) {
                $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')" } }
            }
        }
    }
}

function Test-ScopeIntegrity {
    <#
    .SYNOPSIS
        Whether a Scope chosen for the Run performs the integrity scan: one of them says so.
        Pure.
    #>

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

    [bool]@((Get-DataCollection $Selection 'Scope') | Where-Object { $_ -and [bool](Get-DataProperty $_ 'Integrity') }).Count
}

function Complete-ScopeAppSelection {
    <#
    .SYNOPSIS
        Settles the applications of a Scope that takes none, once the Technician was asked:
        the ones they added are performed, and where they added none, the Scope left the
        applications out, as it always did. Pure.
    .DESCRIPTION
        A Technician asks about the network and Outlook together: the chooser offers the
        applications with every Scope (Select-ScopeCheck -KeepApps), and with one that
        takes none it has only those chosen to begin with that run. Of every Scope, the
        Checks of an application nobody chose are no longer among its Checks: it counts
        what it performs.
    .PARAMETER Chosen
        The application Definitions chosen (Resolve-AppCheck's Selected).
    .PARAMETER Named
        The Technician named the applications (-Apps): they take part whatever the Scope says.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Selection, [AllowNull()][AllowEmptyCollection()]$Chosen, [switch]$Named)

    $names = @($Chosen | Where-Object { $_ } | ForEach-Object { Get-DefinitionApp -Definition $_ } | Where-Object { $_ })
    $appChecks = @((Get-DataCollection $Selection 'AppCheck') | Where-Object { $_ })
    $Selection.Check = @((Get-DataCollection $Selection 'Check') | Where-Object { $appChecks -notcontains $_ -or $names -contains (Get-DefinitionApp -Definition $_) })
    # And of the Checks it performs, which read what it left out: an application nobody
    # chose reads nothing, and is no mistake of the Scope's.
    $all = @(@((Get-DataCollection $Selection 'Check')) + @((Get-DataCollection $Selection 'Omitted')) + $appChecks | Where-Object { $_ })
    $Selection.Unmet = @(Get-ScopeUnmet -Check $Selection.Check -Definition $all)

    if ($Named -or [bool](Get-DataProperty $Selection 'AppsByScope')) { return $Selection }
    if (@($Chosen | Where-Object { $_ }).Count) {
        $Selection.Apps = $true
        $Selection.OmittedApps = @()
        return $Selection
    }
    $Selection.Apps = $false
    $Selection.OmittedApps = @((Get-DataCollection $Selection 'AppCheck') | Where-Object { $_ })
    $Selection
}

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 {
        # A Scope with the integrity scan takes minutes, and says so before it is chosen.
        $detail = $(if ([bool](Get-DataProperty $_ 'Integrity')) { (Get-Text 'Console.Scope.TakesMinutes') -f $_.Name } else { "$($_.Name)" })
        [pscustomobject]@{ Name = $_.Name; Title = $_.Title; Detail = $detail } })
    $chosen = Read-LanternChoice (Get-Text 'Console.Scope.Heading') -Item $items -Label { $_.Title } -Detail { $_.Detail } -Default { -not $_.Name }
    Set-RunPartAnswerMuted
    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') }
    }
    $askScope = -not $asked.Count -and -not $sites.Count -and @($Scope).Count -and (Test-RunInteractive)
    # Asked until the Technician goes on: from the question of the addresses they may go back
    # to this one.
    while ($true) {
        if ($askScope) { $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 -CanGoBack:$askScope | Where-Object { $_ })
            if ($askScope -and @($sites) -contains 'Back') { $sites = @(); Clear-RunPart; continue }
        }
        break
    }
    $selection | Add-Member -NotePropertyName WebsiteMissing -NotePropertyValue ([bool]($forSites -and -not $listed -and -not $sites.Count)) -Force
    # Whether the Scope was asked here: a later question may then go back to it.
    $selection | Add-Member -NotePropertyName Asked -NotePropertyValue ([bool]$askScope) -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 Resolve-RunSelection {
    <#
    .SYNOPSIS
        What the Run is to look at, and which applications: the Scope and the applications
        chosen, asked one after the other, with a way back from the second to the first.
    .DESCRIPTION
        The Scope is asked first where none was given (Resolve-ScopeCheck), then the
        applications (Resolve-AppCheck). Where the Scope was asked, Escape at the question of
        the applications goes back to it, and both are asked again: the Technician who chose
        "Netzwerk" and meant "Schnell" does not have to start the Run anew.
 
        Where the Technician was asked either, what the Run is about to look at is shown
        before it starts, and they start it, go back to the first question, or cancel
        (Read-RunConfirm). A Run that asked nothing is not held up by a question.
 
        Returns Scoped (Resolve-ScopeCheck's selection, completed by the applications chosen
        where the Run was confirmed; Complete-ScopeAppSelection gives the same twice) and
        Selection (Resolve-AppCheck's), Named: whether -Apps named any, and Cancelled:
        whether the Technician cancelled the Run at the confirmation.
    .PARAMETER Apps
        As -Apps was given; Bound says whether it was.
    #>

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

    $named = $Bound -and -not (Test-NoAppsRequested -Requested $Apps)
    $askApps = -not $Bound -and (Test-RunInteractive)
    while ($true) {
        $scoped = Resolve-ScopeCheck -Definition $Definition -Scope $Scope -Requested $Requested -KeepApps:($named -or $askApps) -Website $Website
        $asked = $(if ($scoped.Apps) { $Apps } else { @('None') })
        $selection = Resolve-AppCheck -Definition $scoped.Check -Requested $asked -Adding:(-not $scoped.AppsByScope -and -not $named) -CanGoBack:([bool]$scoped.Asked)
        # A step back starts the questions again in a clean card.
        if ([bool](Get-DataProperty $selection 'Back')) { Clear-RunPart; continue }
        if (-not ($scoped.Asked -or $askApps)) { break }
        $scoped = Complete-ScopeAppSelection -Selection $scoped -Chosen @($selection.Selected) -Named:$named
        $answer = Read-RunConfirm -Summary @(Get-RunSummary -Scoped $scoped -Selection $selection -ScopeShown:([bool]$scoped.Asked) -AppsShown:([bool](Get-DataProperty $selection 'Asked')))
        if ($answer -eq 'Back') { Clear-RunPart; continue }
        if ($answer -eq 'Cancel') { return [pscustomobject]@{ Scoped = $scoped; Selection = $selection; Named = $named; Cancelled = $true } }
        break
    }
    [pscustomobject]@{ Scoped = $scoped; Selection = $selection; Named = $named; Cancelled = $false }
}

function Get-RunSummary {
    <#
    .SYNOPSIS
        What a Run is about to look at, as the lines the Technician confirms it by: the
        Scope, the applications, the websites where there are any, and how many Checks.
        Pure.
    .DESCRIPTION
        A question that was asked has left its answer on the screen; -ScopeShown and
        -AppsShown leave that answer out here, so the confirmation says only what is new.
    .PARAMETER Scoped
        The Scope's selection, completed by the applications chosen (Complete-ScopeAppSelection).
    .PARAMETER Selection
        The applications chosen (Resolve-AppCheck).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Scoped, [AllowNull()]$Selection, [switch]$ScopeShown, [switch]$AppsShown)

    if (-not $ScopeShown) {
        $title = Get-ScopeTitle -Selection $Scoped
        if (-not $title) { $title = Get-Text 'Console.Scope.Everything' }
        (Get-Text 'Console.Confirm.Scope') -f $title
    }

    if (-not $AppsShown) {
        $apps = @((Get-DataCollection $Selection 'Selected') | Where-Object { $_ } | ForEach-Object { Get-DefinitionApp -Definition $_ } | Where-Object { $_ } | Select-Object -Unique)
        $named = $(if ($apps.Count) { $apps -join ', ' } else { Get-Text 'Console.Ask.None' })
        (Get-Text 'Console.Confirm.Apps') -f $named
    }

    $checks = @((Get-DataCollection $Scoped 'Check') | Where-Object { $_ })
    $sites = @($checks | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq 'Website' } | ForEach-Object { "$(Get-DataProperty $_ 'Name')" })
    if ($sites.Count) { (Get-Text 'Console.Confirm.Websites') -f ($sites -join ', ') }

    (Get-Text 'Console.Confirm.Checks') -f $checks.Count
}

function Read-RunConfirm {
    <#
    .SYNOPSIS
        Shows what the Run is about to look at and asks whether to start it: Start, Back
        to the first question, or Cancel. Escape goes back.
    .DESCRIPTION
        What is said is one line, and the three answers stand side by side: the questions
        before it are still on the screen with their answers, and this only adds to them.
        Start is where the pointer starts, so Enter starts the Run the Technician chose,
        and it is what comes back where nobody can be asked. The asking is Lantern's.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Summary = @())

    # Muted: what the Technician chose, read back to them; the question is what stands out.
    $said = @($Summary | Where-Object { $_ })
    if ($said.Count) { Write-ConsoleLine ($said -join ' · ') -Color DarkGray }
    $items = @(
        [pscustomobject]@{ Name = 'Start';  Title = (Get-Text 'Console.Confirm.Start') }
        [pscustomobject]@{ Name = 'Back';   Title = (Get-Text 'Console.Confirm.Back') }
        [pscustomobject]@{ Name = 'Cancel'; Title = (Get-Text 'Console.Confirm.Cancel') }
    )
    $chosen = Invoke-BackableQuestion { Read-LanternChoice (Get-Text 'Console.Confirm.Prompt') -Item $items -Label { $_.Title } -Default { $_.Name -eq 'Start' } -Column 3 }
    if ($null -eq $chosen) { return 'Back' }
    "$($chosen.Name)"
}

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,
        # Whether the Run performs the integrity scan beside the Scope's Checks: no Check
        # Definition holds it, and a narrowed Report says that it ran.
        [switch]$Integrity
    )

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

    $omitted = @((Get-DataCollection $Selection 'Omitted') | ForEach-Object { $_ })
    # The scan's Checks are performed too, and counted with them.
    $scan    = $(if ($Integrity) { @(New-IntegrityCheckDefinition).Count } else { 0 })
    $value   = @((Get-Text 'Value.Scope.Chosen') -f $title, (@((Get-DataCollection $Selection 'Check') | ForEach-Object { $_ }).Count + $scan), $omitted.Count)
    if ($Integrity) { $value += Get-Text 'Value.Scope.Integrity' }
    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
}