Public/Test-HDTTaskSequence.ps1

function Test-HDTTaskSequence {
    <#
        .SYNOPSIS
            Lints an imported sequence for the problems a schema cannot see.
 
        .DESCRIPTION
            Assert-HDTSequenceDocument answers "is this a well formed sequence
            document". This answers a different question: "would this sequence
            actually work on the machine you are about to deploy".
 
            IT RETURNS FINDINGS RATHER THAN THROWING, because it is a lint. The
            console (M8) surfaces them inline while somebody is still editing,
            and a lint that stopped at the first problem would make that
            experience worse than useless.
 
            One Error:
 
              a step whose type NO LOADED MODULE IMPLEMENTS, with the types this
              engine can run listed. Import deliberately does not check this - a
              sequence authored for a workspace whose Modules\ carries a
              third-party step must still import on a machine that does not
 - so this is where an author finds out.
 
            Three Warnings:
 
              a `runIn: WinPE` step AFTER a Restart. Nothing in HDT boots back
              into WinPE, so such a step can only be skipped at runtime. It is a
              warning rather than an error because a sequence may legitimately be
              authored for a machine that PXE-boots between legs.
 
              a %Var% NO SOURCE COULD SUPPLY: not a sequence variable, not an
              engine variable, not set by an EARLIER SetVariable step, and not in
              -KnownVariable. Order matters - a variable set by a later step is
              still a warning, because the condition reading it runs first.
 
              `continueOnError: true` on a Restart. Tolerating a failed reboot
              means continuing in WinPE a sequence that expected the full OS.
 
            IT DOES NOT EVALUATE CONDITIONS. Whether %HDTIsLaptop% is True is a
            property of a machine, and this runs at authoring time where there is
            no machine.
 
        .PARAMETER Sequence
            An Import-HDTSequenceDocument result.
 
        .PARAMETER StepType
            A pre-built Get-HDTStepType registry. Without one it discovers the
            types loaded in this session, which for an authoring machine is the
            right answer.
 
        .PARAMETER KnownVariable
            Names a rules document, a machine override or the gather would
            supply. Everything the engine sets itself is already known.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject[], one per finding:
            Severity (Error | Warning), Index, Step, Message.
 
        .EXAMPLE
            Test-HDTTaskSequence -Sequence $sequence | Format-Table Severity, Index, Step, Message
 
        .EXAMPLE
            Test-HDTTaskSequence -Sequence $sequence -KnownVariable @($rules.Variable.Keys)
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNull()]
        [object] $Sequence,

        [Parameter()]
        [AllowNull()]
        [object[]] $StepType,

        [Parameter()]
        [AllowNull()]
        [string[]] $KnownVariable
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $registry = $StepType
    if ($null -eq $registry) {
        $registry = @(Get-HDTStepType)
    }

    $knownType = @($registry | ForEach-Object { [string] $_.Type })
    $knownTypeText = $knownType -join ', '
    if ([string]::IsNullOrWhiteSpace($knownTypeText)) {
        $knownTypeText = '<none - no step type module is loaded>'
    }

    # Everything a %Var% could legitimately name at this point in the sequence.
    # The engine variables of DESIGN 4.4.1 are always there; the sequence's own
    # defaults are DESIGN 3.1 source 5; a rules document's names arrive through
    # -KnownVariable; and a SetVariable step adds its names as the walk passes it.
    $supplied = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($name in @('_HDTRunId', '_HDTPhase', '_HDTLogPath', '_HDTDeployRoot', '_HDTVersion', '_HDTStepName', '_HDTStepType')) {
        $supplied[$name] = ''
    }
    foreach ($name in @($Sequence.Variable.Keys)) {
        $supplied[[string] $name] = ''
    }
    if ($null -ne $KnownVariable) {
        foreach ($name in @($KnownVariable)) {
            $supplied[[string] $name] = ''
        }
    }

    # AND EVERYTHING THE ENGINE ITSELF SUPPLIES - the facts the gather reads
    # before the sequence begins, and the volumes a partition step publishes as
    # it runs. Neither is a sequence variable, a rule or a SetVariable, so a
    # checker that knew only those three warned about the client template's own
    # Install Operating System step, which reads %HDTOSVolume%.
    #
    # A WARNING THAT IS ALWAYS WRONG IS WORSE THAN NO WARNING: it teaches
    # everybody to ignore the column it appears in.
    #
    # THE LIST IS THE VARIABLE MAP'S, not one kept here: a fact added to the
    # gather stops being warned about without anybody remembering this file.
    foreach ($known in @(Get-HDTVariableMap)) {
        if ([string] $known.Origin -eq 'authored') { continue }

        $supplied[[string] $known.HDTName] = ''
    }

    $finding = New-Object -TypeName System.Collections.ArrayList

    $add = {
        param([string] $Severity, [int] $Index, [string] $Step, [string] $Message)

        [void] $finding.Add([pscustomobject] ([ordered] @{
                    Severity = $Severity
                    Index    = $Index
                    Step     = $Step
                    Message  = $Message
                }))
    }

    $restartSeen = $false

    foreach ($step in @($Sequence.Step)) {

        $index = [int] $step.Index
        $name = [string] $step.Name
        $type = [string] $step.Type

        if ($knownType -notcontains $type) {
            & $add 'Error' $index $name ("the step type '{0}' is not implemented by any loaded module. A step type is a function named Invoke-HDT<Type>Step. The types this engine can run are: {1}." -f $type, $knownTypeText)
        }

        if ($type -eq 'Restart' -and [bool] $step.ContinueOnError) {
            & $add 'Warning' $index $name 'continueOnError: true on a Restart step means a failed reboot is tolerated, which continues the sequence in the phase it was trying to leave.'
        }

        if ($restartSeen -and [string] $step.RunIn -eq 'WinPE') {
            & $add 'Warning' $index $name ("runIn: WinPE on step {0} comes after a Restart step, and nothing in HDT boots back into WinPE, so this step can only be skipped at run time." -f $index)
        }

        # Every %Var% this step could read: its own condition, the conditions of
        # every group it sits in, and each of its type-specific property values.
        $unresolved = New-Object -TypeName System.Collections.ArrayList

        $text = New-Object -TypeName System.Collections.ArrayList
        if (-not [string]::IsNullOrWhiteSpace([string] $step.Condition)) {
            [void] $text.Add([string] $step.Condition)
        }
        foreach ($ancestor in @($step.GroupCondition)) {
            [void] $text.Add([string] $ancestor.Condition)
        }
        foreach ($key in @($step.Property.Keys)) {
            $value = $step.Property[$key]
            if ($value -is [string]) {
                [void] $text.Add($value)
            }
        }

        foreach ($item in $text) {
            [void] (Expand-HDTVariableToken -Value $item -Scope $supplied -Unresolved $unresolved)
        }

        if (@($unresolved).Count -gt 0) {
            & $add 'Warning' $index $name ("{0} variable token(s) are read here that no sequence variable, rule or earlier SetVariable step supplies: {1}. An unsupplied token is left literal at run time, so a condition reading it is false." -f
                @($unresolved).Count, (@($unresolved) -join ', '))
        }

        # A SetVariable step supplies its names to everything AFTER it, which is
        # why this happens at the end of the step rather than at the start.
        if ($type -eq 'SetVariable') {
            if ($step.Property.Contains('variable')) {
                $supplied[[string] $step.Property['variable']] = ''
            }
            if ($step.Property.Contains('variables') -and ($step.Property['variables'] -is [System.Collections.IDictionary])) {
                foreach ($key in @($step.Property['variables'].Keys)) {
                    $supplied[[string] $key] = ''
                }
            }
        }

        if ($type -eq 'Restart') {
            $restartSeen = $true
        }
    }

    return [object[]] $finding.ToArray()
}