Private/Kind.ps1

# The Kind boundary.
#
# A Kind is the named behaviour a Check Definition selects. Every Kind implements the same
# interface, so adding a Check means writing one Gatherer and one Judge and nothing else:
#
# Get-<Kind>Data -Parameters <hashtable> -> gathered data, no judgement
# ConvertTo-<Kind>Finding -Data <object> -Parameters <hashtable> -> Findings, pure
# ConvertTo-<Kind>Section -Data <object> -> Sections, optional
# ConvertTo-<Kind>Event -Data <object> -> event rows, optional
#
# and declare $script:<Kind>DataShape, what the Gatherer promises the Judge. Data off it is
# said in a Finding before the Judge is asked: see Private/DataShape.ps1 and ADR-0011.
#
# A Judge that measured the way to something returns its Measurements among its Findings,
# each naming the Finding it belongs to (New-Measurement -Finding). Invoke-CheckDefinition
# tells the two apart by type. A Kind that measures nothing returns Findings as it did.
# ADR-0009. A Judge that read events out of a log returns its Timed Events the same way,
# each naming the Finding that judged its kind (New-TimedEvent -Finding). ADR-0010. And a
# Judge that observed how things on the network are connected returns its Links so too
# (New-Link), which the Run assembles the Network Map from. ADR-0012.
#
# A Gatherer may additionally declare -Observed, and is then handed what the Checks before
# it gathered, keyed by Kind. This is the whole of how one Check reaches another: the
# Gutcheck script let the stability scan populate script-scoped state that the per-app
# crash analysis read back, guarded such that reordering the two made the dependent Check
# vanish rather than fail. The dependency was real and remains; what changes is that it is
# an argument a Gatherer asks for by name, visible in its signature and impossible to
# satisfy by accident.
#
# What the Elevated Part gathered is in there too, from the first Check of the Main Part
# on. It crossed between two processes as CliXML, so it is read like a fixture and not like
# an object this process built: a Kind that is absent was not performed or did not come
# back, and every property is asked for with Get-DataProperty and Get-DataCollection.
#
# One key in there is no Kind: 'AppSelection', the applications chosen for this Run and
# performed in it, which the Run puts there before the first Check of the Main Part. See
# ConvertTo-AppSelectionObserved. No Kind can take its place: a Kind is a file in
# Private/Kinds, and there is none of that name.
#
# A second is 'RunUser': the account the Run runs as, the one working at the machine, and
# whether they differ, which the Run puts there at the same moment. It is how a Check about
# the User learns that the Run is not in the User's Session; none compares the two names
# itself. See ConvertTo-RunUserObserved. A Check reads it with Get-ObservedRunUser, and its
# Judge asks Get-UserSessionObstacle, with the gathered RunUser and whether the Run was
# elevated, whether what was read is the User's own (ADR-0016).
#
# A third is 'RunFolder': the folder the Report is written to, as Path. It is how a Check
# that keeps a file for second level - the Website Check's net log - puts it beside the
# Report instead of somewhere on the machine nobody will look.
#
# Another is 'RunProcesses': the processes of the Run beyond its own, as
# ElevatedPartProcessId - the Elevated Part it started, none where it started none. It is
# how the Memory Check leaves out what Gutcheck itself holds, without reading another
# file's state.
#
# A fourth is 'Gathered': what every Check gathered, each as Kind, Check (its Name), App
# and Data. Under its Kind's own key stands only what the last Check of that Kind gathered,
# and that stays so; a Kind that needs every Check of another - the Kerberos Kind, every
# HTTPS Service the HttpsEndpoint and Server Checks reached - reads this list instead. It
# is the Main Part's: what crosses from the Elevated Part stays keyed by Kind only, and
# stands first in it, one entry per Kind in the alphabetical order of the Kinds, not in
# the order they were performed (ConvertTo-ObservedGathered). The Main Part's own Checks
# follow in the order performed. Absent before the first Check has gathered anything. See
# Add-ObservedData.
#
# The Kind vocabulary is closed. It is built once at import from the Kinds this module
# ships, and a Definition selects from it by name; it can never widen it. ADR-0001 rests
# on a Definition being incapable of introducing behaviour, and resolving a Definition's
# string through Get-Command would not be that: a name this module does not implement
# falls through to whatever function of that name happens to exist in the session.
#
# Check Definitions evolve in the Checks Repo independently of the module on the Gallery,
# so a Run will meet Definitions it cannot perform. Every such case fails soft: a Finding
# that names what was skipped and why, never an exception and never a silent omission.

$script:KindVocabulary = @{}

function Initialize-KindVocabulary {
    <#
    .SYNOPSIS
        Builds the closed set of Kinds this module implements. Called once, at import.
    .DESCRIPTION
        A Kind is shipped as Private/Kinds/<Kind>.ps1 and is registered only when that file
        defines the Gatherer and Judge the interface requires. A Kind file that defines
        neither leaves the Kind unregistered, which the Local Definitions test catches.
    #>

    [CmdletBinding()]
    param()

    $script:KindVocabulary = @{}

    foreach ($file in Get-ChildItem -Path (Join-Path $PSScriptRoot 'Kinds') -Filter '*.ps1' -File) {
        $kind     = $file.BaseName
        $judge    = Get-Command -Name ('ConvertTo-{0}Finding' -f $kind) -CommandType Function -ErrorAction SilentlyContinue
        $gatherer = Get-Command -Name ('Get-{0}Data' -f $kind)          -CommandType Function -ErrorAction SilentlyContinue
        if (-not $judge -or -not $gatherer) { continue }

        $section = Get-Command -Name ('ConvertTo-{0}Section' -f $kind) -CommandType Function -ErrorAction SilentlyContinue
        $sectionName = $null
        if ($section) { $sectionName = $section.Name }

        # Not $event: that is an automatic variable, and assigning to it inside a module
        # that also loads event-reading Kinds is a collision waiting for a long evening.
        $eventCommand = Get-Command -Name ('ConvertTo-{0}Event' -f $kind) -CommandType Function -ErrorAction SilentlyContinue
        $eventName = $null
        if ($eventCommand) { $eventName = $eventCommand.Name }

        $script:KindVocabulary[$kind] = [pscustomobject]@{
            PSTypeName     = 'Gutcheck.KindImplementation'
            Kind           = $kind
            Gatherer       = $gatherer.Name
            Judge          = $judge.Name
            SectionBuilder = $sectionName
            EventBuilder   = $eventName
            # Whether this Kind can only be performed with admin rights, declared by the
            # Kind file as $script:<Kind>NeedsAdmin. A property of the Kind rather than of
            # a Definition: which rights a reliability counter needs is a fact about
            # Windows, not something a Customer has an opinion about, and a Definition that
            # could claim otherwise would be a Definition deciding where code runs.
            NeedsAdmin     = [bool](Get-KindDeclaration -Kind $kind -Name 'NeedsAdmin')
            # What the Gatherer promises its Judge, declared by the Kind file as
            # $script:<Kind>DataShape. See Private/DataShape.ps1 and ADR-0011.
            DataShape      = Get-KindDeclaration -Kind $kind -Name 'DataShape'
            # Whether this Gatherer asked to see what earlier Checks gathered. Read off
            # its signature rather than declared in a list, so a Kind cannot claim the
            # dependency without taking the argument.
            WantsObserved  = $gatherer.Parameters.ContainsKey('Observed')
            # Whether the Judge, and the Section, asked for the Situation. The same rule.
            JudgeWantsSituation   = $judge.Parameters.ContainsKey('Situation')
            SectionWantsSituation = [bool]($section -and $section.Parameters.ContainsKey('Situation'))
        }
    }
}

function Get-KindDeclaration {
    <#
    .SYNOPSIS
        A named fact a Kind file declares about itself, or $null when it declares none.
    .DESCRIPTION
        Read rather than invoked, so building the vocabulary stays what its comment says it
        is: a lookup table, with nothing examined and nothing run.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Kind,
        [Parameter(Mandatory)][string]$Name
    )

    $variable = Get-Variable -Name ('{0}{1}' -f $Kind, $Name) -Scope Script -ErrorAction SilentlyContinue
    if (-not $variable) { return $null }
    $variable.Value
}

function Get-KindImplementation {
    <#
    .SYNOPSIS
        The commands implementing a Kind, or $null when this module does not implement it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Kind)

    if (-not $Kind) { return $null }
    $script:KindVocabulary[$Kind]
}

function Test-CheckSkipped {
    <#
    .SYNOPSIS
        Whether the Technician asked this Run not to perform a Check: by its Name, or by
        its Kind. Pure.
    .DESCRIPTION
        The one rule, for everything that has to know: the Check itself, which says that
        it was skipped; the Run, which must not hand a skipped Check to the Elevated Part
        or ask for admin rights on its account; and what tells the Checks which
        applications are looked at.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Definition, [AllowNull()][AllowEmptyCollection()][string[]]$Skip = @())

    $name = "$(Get-DataProperty $Definition 'Name')"
    $kind = "$(Get-DataProperty $Definition 'Kind')"
    if (-not $name) { $name = $kind }
    [bool]((@($Skip) -contains $name -and $name) -or (@($Skip) -contains $kind -and $kind))
}

function Get-ObservedRunUser {
    <#
    .SYNOPSIS
        Whose Session the Run is in, as the Run decided it once for every Check about the
        User: $Observed['RunUser'] (ConvertTo-RunUserObserved), or $null where the Check is
        performed without a Run around it. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()][hashtable]$Observed)

    if (-not $Observed -or -not $Observed.ContainsKey('RunUser')) { return $null }
    $Observed['RunUser']
}

function Get-UserSessionObstacle {
    <#
    .SYNOPSIS
        Why what a Check read of the User's own state is not the User's: 'Elevated' where
        the Run had admin rights, which give it a logon session of its own, 'OtherUser'
        where it runs as another account than the one at the machine, and nothing where it
        is the User's and can be judged. Pure.
    .DESCRIPTION
        The one test for every Check about the User's own state (ADR-0016). Elevated comes
        first: it is the more common, and its Hint - start without admin rights - also
        settles the other. RunUser is what the Check gathered of $Observed['RunUser'];
        where it is absent the names are not compared here: whether they differ is decided
        once in a Run.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$RunUser, [AllowNull()]$IsElevated)

    if ([bool]$IsElevated) { return 'Elevated' }
    if ([bool](Get-DataProperty $RunUser 'Differs')) { return 'OtherUser' }
}

function Add-ObservedData {
    <#
    .SYNOPSIS
        Puts what one Check gathered where the Checks after it are handed it: under its
        Kind, replacing what an earlier Check of that Kind left there, and at the end of
        'Gathered' beside every Check before it. Nothing for a Check that gathered nothing.
    .DESCRIPTION
        Used by the Main Part only: what crosses from the Elevated Part stays keyed by Kind,
        and ConvertTo-ObservedGathered starts 'Gathered' from it. A 'Gathered' that is not a
        list of this Part's is copied into one before the first entry is added.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$Observed, [AllowNull()]$Result, [AllowNull()]$Definition)

    $kind = "$(Get-DataProperty $Result 'Kind')"
    $data = Get-DataProperty $Result 'Data'
    if (-not $kind -or $null -eq $data) { return }
    $Observed[$kind] = $data

    $list = $null
    if ($Observed.ContainsKey('Gathered')) { $list = $Observed['Gathered'] }
    if ($list -isnot [System.Collections.Generic.List[psobject]]) {
        $copy = New-Object System.Collections.Generic.List[psobject]
        foreach ($entry in @($list | Where-Object { $null -ne $_ })) { $copy.Add($entry) }
        $list = $copy
        $Observed['Gathered'] = $list
    }
    $name = "$(Get-DataProperty $Definition 'Name')"
    if (-not $name) { $name = $kind }
    $list.Add([pscustomobject]@{
        PSTypeName = 'Gutcheck.Data.Gathered'
        Kind       = $kind
        Check      = $name
        App        = $(if ($null -ne $Definition) { Get-DefinitionApp -Definition $Definition } else { '' })
        Data       = $data
    })
}

function ConvertTo-ObservedGathered {
    <#
    .SYNOPSIS
        'Gathered' as it starts in the Main Part: one entry for each Kind the Elevated Part
        handed over, as it came, named by its Kind. Empty where it handed over none. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()][hashtable]$Observed)

    $list = New-Object System.Collections.Generic.List[psobject]
    if ($Observed) {
        foreach ($kind in @($Observed.Keys | Sort-Object)) {
            if ($kind -in @('AppSelection', 'RunUser', 'RunFolder', 'RunProcesses', 'Gathered') -or $null -eq $Observed[$kind]) { continue }
            $list.Add([pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Gathered'; Kind = "$kind"; Check = "$kind"; App = ''; Data = $Observed[$kind] })
        }
    }
    , $list
}

function Get-ObservedGathered {
    <#
    .SYNOPSIS
        What every Check of a Kind gathered, in the order performed; where 'Gathered' is not
        there - data of an older module, a test - what stands under the Kind's own key. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()][hashtable]$Observed, [Parameter(Mandatory)][string]$Kind)

    if (-not $Observed) { return }
    if ($Observed.ContainsKey('Gathered') -and $null -ne $Observed['Gathered']) {
        @($Observed['Gathered']) | Where-Object { $null -ne $_ -and "$(Get-DataProperty $_ 'Kind')" -eq $Kind } |
            ForEach-Object { Get-DataProperty $_ 'Data' } | Where-Object { $null -ne $_ }
        return
    }
    if ($Observed.ContainsKey($Kind) -and $null -ne $Observed[$Kind]) { $Observed[$Kind] }
}

function Invoke-CheckDefinition {
    <#
    .SYNOPSIS
        Performs one Check: gather, then judge, and return what came back.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Definition,
        [Parameter(Mandatory)][version]$ModuleVersion,

        # Checks the Technician asked this Run not to perform, by Check Name or by Kind.
        [AllowNull()][AllowEmptyCollection()][string[]]$Skip = @(),

        # What the Checks before this one gathered, keyed by Kind, under 'AppSelection' what
        # the Run chose, under 'RunUser' whose Session it is in and under 'RunFolder' where
        # the Report goes. Handed only to a
        # Gatherer that declared -Observed.
        [AllowNull()][hashtable]$Observed = @{},

        # What a Hint may assume about the machine. Handed only to a Judge or a Section
        # that declared -Situation.
        [AllowNull()]$Situation
    )

    $name       = Get-DataProperty $Definition 'Name'
    $kind       = Get-DataProperty $Definition 'Kind'
    $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $Definition 'Parameters')
    if (-not $name) { $name = $kind }

    # The application an application's Check is about, as its Definition names it. Several
    # Checks share one: "App: Teams", "App: Teams servers" and "App: Teams cache" are Teams.
    $app = "$(Get-Parameter $parameters 'App' '')"

    # A Check the Technician skipped is still a Check the Report has to account for. The
    # long Checks - the disk write test, the stress test - are the ones most often skipped
    # and the ones whose silent absence would most look like a clean machine.
    if (Test-CheckSkipped -Definition $Definition -Skip $Skip) {
        return New-CheckOutcome -Finding (New-Finding -Category Gutcheck -Check $name -Severity INFO `
            -Value (Get-Text 'Value.Kind.SkippedOnRequest' $name) `
            -Hint (Get-Text 'Hint.Kind.ThisCheckWasNotPerformed') `
            -Unobserved @(Get-SignalOfKind -Kind $kind)) -CheckName $name -App $app
    }

    $required = Get-DataProperty $Definition 'MinimumModuleVersion'
    if ($required -and [version]$required -gt $ModuleVersion) {
        return New-CheckOutcome -Finding (New-Finding -Category Gutcheck -Check $name -Severity INFO `
            -Value (Get-Text 'Value.Kind.SkippedNeedsVersion' $name $required $ModuleVersion) `
            -Hint (Get-Text 'Hint.Kind.UpdateGutcheckToPerformThis') `
            -Unobserved @(Get-SignalOfKind -Kind $kind)) -CheckName $name -App $app
    }

    $implementation = Get-KindImplementation -Kind $kind
    if (-not $implementation) {
        return New-CheckOutcome -Finding (New-Finding -Category Gutcheck -Check $name -Severity INFO `
            -Value (Get-Text 'Value.Kind.UnknownKind' $name $kind $ModuleVersion) `
            -Hint (Get-Text 'Hint.Kind.UpdateGutcheckTheseCheckDefinitions')) -CheckName $name -App $app
    }

    try {
        $arguments = @{ Parameters = $parameters }
        if ($implementation.WantsObserved) { $arguments['Observed'] = $Observed }
        $data = & $implementation.Gatherer @arguments
    }
    catch {
        # A Gatherer that could not run is a Finding, not a lost Run: a Check that failed
        # must be visible in the Report rather than simply absent from it.
        return New-CheckOutcome -Finding (New-Finding -Category Gutcheck -Check $name -Severity INFO `
            -Value (Get-Text 'Value.Kind.NotPerformed' $name $_.Exception.Message) `
            -Hint (Get-Text 'Hint.Kind.TheCheckFoundNothingBecause') `
            -Unobserved @(Get-SignalOfKind -Kind $kind)) -CheckName $name -App $app
    }

    # Which Check produced this data, stamped on the way out the way Set-FindingPrivilege
    # stamps Privilege onto Findings: the Gatherer cannot know its own Check's Name,
    # because the Name lives in the Definition and a Kind may be named by several. A later
    # Check handed this through -Observed can then say where an answer came from.
    if ($null -ne $data -and $data -is [psobject] -and $data -isnot [hashtable]) {
        $data | Add-Member -NotePropertyName 'CheckName' -NotePropertyValue $name -Force
    }

    # Data off the Kind's Data Shape is a defect of Gutcheck that would otherwise read as
    # Windows not answering: the Judge is handed $null for the property it asks for. Said
    # here, at the boundary, in whichever Part performs the Check - before the Elevated
    # Part's data crosses as CliXML, so that what the crossing does is not blamed on the
    # Gatherer. The Judge is still asked: what it can read is still true. ADR-0011.
    $offShape = $null
    if ($implementation.DataShape) {
        $mismatches = @(Get-DataShapeMismatch -Shape $implementation.DataShape -Data $data)
        if ($mismatches.Count) {
            $offShape = New-Finding -Category Gutcheck -Check $name -Severity INFO `
                -Value (Get-Text 'Value.Kind.OffShape' (($mismatches | ForEach-Object {
                    if ($_.Shortfall -eq 'List') { Get-Text 'Value.Kind.OffShape.List' $_.Property }
                    else                         { Get-Text 'Value.Kind.OffShape.Missing' $_.Property }
                }) -join ', ')) `
                -Meaning (Get-Text 'Meaning.Kind.OffShape') -Hint (Get-Text 'Hint.Kind.OffShape')
        }
    }

    # A Judge should never throw, and one did: on a threshold a Definition gave as text.
    # That ended the Run, and every Check after it with it. What cannot be judged is one
    # Finding saying so, like what cannot be gathered - the data is still handed on, and
    # the Sections and events are each built on their own, so that one failing does not
    # cost the Report the others.
    $findings = @()
    $measurements = @()
    $timed = @()
    $links = @()
    $judgeArguments = @{ Data = $data; Parameters = $parameters }
    if ($implementation.JudgeWantsSituation) { $judgeArguments['Situation'] = $Situation }
    try {
        # A Judge returns its Measurements among its Findings, and they are told apart here,
        # by type: a Kind that measures nothing stays as it was. ADR-0009. Its Timed Events
        # likewise (ADR-0010), and its Links (ADR-0012).
        $judged = @(& $implementation.Judge @judgeArguments)
        $measurements = @($judged | Where-Object { $_ -and $_.PSObject.TypeNames -contains 'Gutcheck.Measurement' })
        $timed        = @($judged | Where-Object { $_ -and $_.PSObject.TypeNames -contains 'Gutcheck.TimedEvent' })
        $links        = @($judged | Where-Object { $_ -and $_.PSObject.TypeNames -contains 'Gutcheck.Link' })
        $findings     = @($judged | Where-Object { $_ -and $_.PSObject.TypeNames -notcontains 'Gutcheck.Measurement' -and
                                                   $_.PSObject.TypeNames -notcontains 'Gutcheck.TimedEvent' -and
                                                   $_.PSObject.TypeNames -notcontains 'Gutcheck.Link' })
    }
    catch {
        $measurements = @()
        $timed = @()
        $links = @()
        $findings = @(New-Finding -Category Gutcheck -Check $name -Severity INFO `
            -Value (Get-Text 'Value.Kind.NotJudged' $name $_.Exception.Message) `
            -Hint (Get-Text 'Hint.Kind.TheCheckFoundNothingBecause') `
            -Unobserved @(Get-SignalOfKind -Kind $kind))
    }
    if ($offShape) { $findings = @($findings) + @($offShape) }
    $sections = @()
    if ($implementation.SectionBuilder) {
        $sectionArguments = @{ Data = $data }
        if ($implementation.SectionWantsSituation) { $sectionArguments['Situation'] = $Situation }
        try { $sections = @(& $implementation.SectionBuilder @sectionArguments) } catch { $sections = @() }
    }
    $events = @()
    if ($implementation.EventBuilder) {
        try { $events = @(& $implementation.EventBuilder -Data $data) } catch { $events = @() }
    }

    New-CheckOutcome -Finding $findings -Section $sections -EventLogEntry $events -Kind $kind -Data $data `
        -CheckName $name -App $app -Measurement $measurements -TimedEvent $timed -Link $links
}

function New-CheckOutcome {
    <#
    .SYNOPSIS
        What one Check produced: Findings, Sections, events, and what it gathered.
    .DESCRIPTION
        Data is carried so a later Check can be handed it by name. It is the Run that
        hands it over, never the Check that reaches for it, which is the difference
        between a dependency and the shared mutable state this replaced.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [AllowNull()][AllowEmptyCollection()]$Section,

        # Not -Event: $Event is a PowerShell automatic variable, and a parameter of that
        # name shadows it inside every function that takes one. Named as Write-Report
        # already names the same rows.
        [AllowNull()][AllowEmptyCollection()]$EventLogEntry,

        [AllowEmptyString()][string]$Kind = '',
        [AllowNull()]$Data,

        # Which Check this is the outcome of, stamped onto everything it produced.
        [AllowEmptyString()][string]$CheckName = '',
        [AllowEmptyString()][string]$App = '',

        # The numbers the Judge measured the way with. See Private/Measurement.ps1.
        [AllowNull()][AllowEmptyCollection()]$Measurement,

        # What the Judge read out of a log that happened at a time. See Private/TimedEvent.ps1.
        [AllowNull()][AllowEmptyCollection()]$TimedEvent,

        # How the things on the network the Judge observed are connected. See Private/Link.ps1.
        [AllowNull()][AllowEmptyCollection()]$Link
    )
    [pscustomobject]@{
        PSTypeName  = 'Gutcheck.CheckOutcome'
        Kind        = $Kind
        Finding     = @($Finding       | Where-Object { $_ } | Set-CheckOrigin -CheckName $CheckName -App $App)
        Section     = @($Section       | Where-Object { $_ } | Set-CheckOrigin -CheckName $CheckName -App $App -Kind $Kind)
        Event       = @($EventLogEntry | Where-Object { $_ })
        Measurement = @($Measurement   | Where-Object { $_ } | Set-CheckOrigin -CheckName $CheckName -App $App)
        TimedEvent  = @($TimedEvent    | Where-Object { $_ } | Set-CheckOrigin -CheckName $CheckName -App $App)
        Link        = @(@($Link | Where-Object { $_ }) + @(New-CheckAppLink -Link $Link -App $App) | Set-CheckOrigin -CheckName $CheckName -App $App)
        Data        = $Data
    }
}

function New-CheckAppLink {
    <#
    .SYNOPSIS
        That the App of a Check uses each Service its Judge put on a Server, and each Windows
        service it looked at on the Target Machine: one Link Uses for each, once. Nothing for
        a Check of no App. Pure.
    .DESCRIPTION
        A Judge does not know its App - one Kind is named by many Definitions - and the Run
        does, where it stamps the App on (ADR-0012, follow-up of #156). That the App's Check
        reached the Service is observed: the Link OnServer says it was there.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Link, [AllowEmptyString()][string]$App = '')

    if (-not "$App".Trim()) { return }
    $services = @(
        $Link | Where-Object { $_ -and "$($_.Kind)" -eq 'OnServer' -and "$($_.From.Thing)" -eq 'Service' } | ForEach-Object { $_.From }
        $Link | Where-Object { $_ -and "$($_.Kind)" -eq 'HasService' } | ForEach-Object { $_.To })
    if (-not $services.Count) { return }
    $user = New-LinkEnd -Thing App -Name $App
    Select-UniqueLink -Link @($services | ForEach-Object { New-Link -Kind Uses -From $user -To $_ })
}