Private/Definition.ps1

# Check Definitions and where a Run's Definitions came from.
#
# A Check Definition is data: it names a Kind the module implements and supplies that
# Kind's parameters. It can never introduce behaviour (ADR-0001).
#
# There are exactly two sources and no cache. A Run either fetched from the Checks Repo or
# it used the Local Definitions shipped inside the module, and the Report says which. A Run
# that used one while claiming the other is the silent failure this design exists to
# prevent, so resolution keeps the Checks and the Provenance in one object and never
# assembles them separately.

function Get-LocalDefinitionPath {
    [CmdletBinding()]
    param()
    Join-Path $PSScriptRoot '..\Definitions\checks.json' | Resolve-Path | Select-Object -ExpandProperty Path
}

function Read-CheckDefinitionDocument {
    <#
    .SYNOPSIS
        Reads a Check Definition document from a file into the shape a Run uses.
    #>

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

    ConvertFrom-CheckDefinitionJson -Document (Get-Content -Path $Path -Raw -Encoding UTF8)
}

function ConvertFrom-CheckDefinitionJson {
    <#
    .SYNOPSIS
        Turns a Check Definition document's JSON into the shape a Run uses.
    .DESCRIPTION
        Separate from reading a file so that a document fetched from the Checks Repo and
        one shipped inside the module go through exactly the same parse. Two parsers would
        eventually disagree, and a Definition that behaves differently depending on where
        it came from is the failure Provenance exists to make visible.
    #>

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

    # Not $document: PowerShell variable names are case-insensitive, so assigning the
    # parsed object back over a [string] parameter coerces it straight back into a string
    # and every property below reads as empty - silently, and only at runtime.
    $content = $Document | ConvertFrom-Json

    $generated = $null
    if ($content.Generated) {
        $parsedDate = [datetime]::MinValue
        if ([datetime]::TryParse([string]$content.Generated, [Globalization.CultureInfo]::InvariantCulture,
                                 [Globalization.DateTimeStyles]::None, [ref]$parsedDate)) {
            $generated = $parsedDate
        }
    }

    $checks = @($content.Checks | Where-Object { $_ } | ForEach-Object {
        [pscustomobject]@{
            PSTypeName           = 'Gutcheck.CheckDefinition'
            Name                 = $_.Name
            Kind                 = $_.Kind
            Parameters           = ConvertTo-ParameterHashtable $_.Parameters
            MinimumModuleVersion = $_.MinimumModuleVersion
        }
    })

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.CheckDefinitionDocument'
        Version    = [string]$content.Version
        Generated  = $generated
        Check      = $checks
    }
}

function Resolve-CheckDefinition {
    <#
    .SYNOPSIS
        Decides which Check Definitions a Run uses, and states where they came from.
    .PARAMETER Fetched
        The document fetched from the Checks Repo, or $null when no fetch happened. There
        is no third state: an absent fetch means Local Definitions, not a stale cache.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$LocalPath,
        [AllowNull()]$Fetched,
        [Parameter(Mandatory)][version]$ModuleVersion
    )

    if ($Fetched) {
        $document = $Fetched
        $source   = Get-Text 'Provenance.Source.Published'
    }
    else {
        $document = Read-CheckDefinitionDocument -Path $LocalPath
        $source   = Get-Text 'Provenance.Source.Local'
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.ResolvedDefinitions'
        Check      = $document.Check
        Provenance = [pscustomobject]@{
            PSTypeName    = 'Gutcheck.Provenance'
            Source        = $source
            Version       = $document.Version
            Generated     = $document.Generated
            ModuleVersion = $ModuleVersion
        }
    }
}

function Get-ProvenanceStatement {
    <#
    .SYNOPSIS
        The sentence every Report carries saying which Definitions produced it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]$Provenance,
        [datetime]$AsOf = (Get-Date)
    )

    if ($null -eq $Provenance.Generated) {
        $age = Get-Text 'Provenance.Age.Unknown'
    }
    else {
        $days = [int][math]::Floor(($AsOf.Date - ([datetime]$Provenance.Generated).Date).TotalDays)
        if ($days -le 0)     { $age = Get-Text 'Provenance.Age.Today' }
        elseif ($days -eq 1) { $age = Get-Text 'Provenance.Age.OneDay' }
        else                 { $age = Get-Text 'Provenance.Age.Days' $days }
    }

    (Get-Text 'Provenance.Statement') -f `
        $Provenance.Source, $Provenance.Version, $age, $Provenance.ModuleVersion
}

function New-ProvenanceFinding {
    <#
    .SYNOPSIS
        Provenance as a Finding, so it reaches a Technician who reads only the Findings.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Provenance,
        [datetime]$AsOf = (Get-Date)
    )

    New-Finding -Category Gutcheck -Check (Get-Text 'Check.Definition.CheckDefinitions') -Severity INFO `
        -Value (Get-ProvenanceStatement -Provenance $Provenance -AsOf $AsOf)
}