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 Get-DefinitionFingerprint {
    <#
    .SYNOPSIS
        A short mark of exactly which document this is. Pure.
    .DESCRIPTION
        A version says which document somebody meant to publish; it is a field in the
        document, and whoever edits a threshold in the library can leave it as it was. Two
        different documents then carry one version, and a Report cannot say which of them
        a Run used. The fingerprint is worked out from the document itself, by the Run that
        read it, so it changes whenever the document does and needs nobody to remember it.
 
        Eight characters of a SHA-256: enough to tell documents apart at a glance and
        short enough to read out over the phone. It is not a signature and proves nothing
        about who wrote the document.
 
        The byte order mark, the kind of line ending and space at the very end are left
        out of it. An editor or a download changes those without changing a single Check,
        and a mark that moved for that would stop being believed.
    #>

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

    $text = $Document.TrimStart([char]0xFEFF) -replace "`r`n", "`n" -replace "`r", "`n"
    $text = $text.TrimEnd()

    $sha = [System.Security.Cryptography.SHA256]::Create()
    try     { $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($text)) }
    finally { $sha.Dispose() }

    (-join ($hash[0..3] | ForEach-Object { $_.ToString('x2') }))
}

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
        # Of the text as it arrived, before anything was parsed out of it.
        Fingerprint = Get-DefinitionFingerprint -Document $Document
        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
            Fingerprint   = Get-DataProperty $document 'Fingerprint'
            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 {
        $generated = [datetime]$Provenance.Generated
        $days = [int][math]::Floor(($AsOf.Date - $generated.Date).TotalDays)
        if ($days -le 0) {
            # With the time when the document carries one: two documents published on the
            # same day are otherwise both "created today". A document dated without a time
            # reads as midnight, and midnight is not a time anybody published at.
            if ($generated.TimeOfDay.Ticks) { $age = Get-Text 'Provenance.Age.TodayAt' $generated.ToString('HH:mm') }
            else                            { $age = Get-Text 'Provenance.Age.Today' }
        }
        elseif ($days -eq 1) {
            # Published at 23:58 and read at 00:02 is "one day old" by the calendar and
            # four minutes old by the clock. With a time, say when yesterday.
            if ($generated.TimeOfDay.Ticks) { $age = Get-Text 'Provenance.Age.YesterdayAt' $generated.ToString('HH:mm') }
            else                            { $age = Get-Text 'Provenance.Age.OneDay' }
        }
        else                 { $age = Get-Text 'Provenance.Age.Days' $days }
    }

    # The fingerprint beside the version, because the version alone does not say which
    # document: see Get-DefinitionFingerprint. Absent on Provenance built before it existed.
    $version     = "$($Provenance.Version)"
    $fingerprint = "$(Get-DataProperty $Provenance 'Fingerprint')"
    if ($fingerprint) { $version = Get-Text 'Provenance.VersionWithFingerprint' $version $fingerprint }

    (Get-Text 'Provenance.Statement') -f `
        $Provenance.Source, $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)
}