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
#
# 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.
#
# 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')
            # 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')
        }
    }
}

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 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. Handed only to a
        # Gatherer that declared -Observed.
        [AllowNull()][hashtable]$Observed = @{}
    )

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

    # 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 (@($Skip) -contains $name -or (@($Skip) -contains $kind -and $kind)) {
        return New-CheckOutcome -Finding (New-Finding -Category Gutcheck -Check $name -Severity INFO `
            -Value ("Check '{0}' was skipped at the Technician's request" -f $name) `
            -Hint (Get-Text 'Hint.Kind.ThisCheckWasNotPerformed'))
    }

    $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 ("Check '{0}' was skipped: it needs Gutcheck {1} and this machine has {2}" -f $name, $required, $ModuleVersion) `
            -Hint (Get-Text 'Hint.Kind.UpdateGutcheckToPerformThis'))
    }

    $implementation = Get-KindImplementation -Kind $kind
    if (-not $implementation) {
        return New-CheckOutcome -Finding (New-Finding -Category Gutcheck -Check $name -Severity INFO `
            -Value ("Check '{0}' names the Kind '{1}', which Gutcheck {2} does not implement" -f $name, $kind, $ModuleVersion) `
            -Hint (Get-Text 'Hint.Kind.UpdateGutcheckTheseCheckDefinitions'))
    }

    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 ("Check '{0}' could not be performed: {1}" -f $name, $_.Exception.Message) `
            -Hint (Get-Text 'Hint.Kind.TheCheckFoundNothingBecause'))
    }

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

    $findings = @(& $implementation.Judge -Data $data -Parameters $parameters)
    $sections = @()
    if ($implementation.SectionBuilder) {
        $sections = @(& $implementation.SectionBuilder -Data $data)
    }
    $events = @()
    if ($implementation.EventBuilder) {
        $events = @(& $implementation.EventBuilder -Data $data)
    }

    New-CheckOutcome -Finding $findings -Section $sections -EventLogEntry $events -Kind $kind -Data $data
}

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
    )
    [pscustomobject]@{
        PSTypeName = 'Gutcheck.CheckOutcome'
        Kind       = $Kind
        Finding    = @($Finding       | Where-Object { $_ })
        Section    = @($Section       | Where-Object { $_ })
        Event      = @($EventLogEntry | Where-Object { $_ })
        Data       = $Data
    }
}