Public/Resolve-HDTVariable.ps1

function Resolve-HDTVariable {
    <#
        .SYNOPSIS
            Resolves the deployment variables from all five sources and records
            where every value came from.
 
        .DESCRIPTION
            The variable engine: five sources in precedence order,
            first-match-wins rule evaluation, %Var% expansion, and
            setFrom: script rules - with a provenance record for every resolved
            variable, which is the whole point ("the single biggest
            debugging pain in MDT is not knowing why HDTComputerName ended up as
            it did").
 
            THE SOURCES, IN ORDER. Each is applied in turn and nothing overwrites
            a variable an earlier source already resolved:
 
              1. -CommandLine what the technician typed -> CommandLine
              2. -MachineOverride Control\machines\<UUID>.yaml -> MachineOverride
              3. -RuleDocument rules.yaml, top to bottom -> Rule / RuleScript
              4. -Fact gathered facts -> GatheredFact
              5. -SequenceDefault sequence.yaml defaults -> SequenceDefault
 
            Precedence is therefore write order rather than a comparison:
            Add-HDTResolvedVariable refuses to overwrite, so applying the sources
            in this order IS the precedence, and a later fallback rule can only
            fill what nothing above it set.
 
            THE SCOPE. One dictionary of RAW, unexpanded values, seeded with the
            sequence defaults and then the facts, and updated by every assignment.
            Lookup precedence falls out of write order: an assigned value shadows
            a fact, a fact shadows a default. The scope is what `when` matching
            and %Var% expansion read; the EXPANDED value is what lands in the
            result. Keeping raw values is what makes a cycle detectable, since two
            variables that reference each other only look cyclic before expansion.
 
            WITHIN A RULE, set: keys are applied in document order and the scope
            updates as each is applied - so a later key may expand a %Var% an
            earlier key set, and a later rule may match on a value an earlier rule
            set. Rules are never short-circuited: every rule is evaluated, because
            it is variables that are first-match-wins, not rules.
 
            IT TOUCHES NOTHING. No filesystem service, no CIM, no script
            execution: the rule document and the machine override are loaded by
            their own functions and handed in, and a setFrom rule reaches its
            script only through -ScriptInvoker. That is what lets the whole engine
            run under Pester against fakes and is why phase 03 can
            swap in the real invoker unchanged.
 
        .PARAMETER CommandLine
            Source 1. Variables the technician supplied.
 
        .PARAMETER MachineOverride
            Source 2. The Variable member of Get-HDTMachineOverride's result.
 
        .PARAMETER MachineOverridePath
            The file those overrides came from, recorded as the provenance File.
            Get-HDTMachineOverride returns it as Path.
 
        .PARAMETER RuleDocument
            Source 3. An Import-HDTRuleDocument result: Path, SchemaVersion and
            Rule.
 
        .PARAMETER Fact
            Source 4. A Get-HDTMachineFact result.
 
        .PARAMETER SequenceDefault
            Source 5. The defaults declared by sequence.yaml.
 
        .PARAMETER ScriptInvoker
            An IScriptInvoker, required only if a matching rule uses setFrom. The
            script receives a COPY of the scope, so a user script cannot mutate
            engine state, and the object it emits becomes variables: a
            [pscustomobject] by its properties, an IDictionary by its keys.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject:
 
              Variable ordered, case-insensitive: name -> expanded value
              Provenance ordered, case-insensitive: name -> record of
                          Name, Value, Source, Rule, RuleIndex, File, RawValue,
                          Expanded, Order
              Unresolved [string[]] the distinct %Var% names nothing supplied,
                          sorted ordinally
 
            Source is a closed set: CommandLine, MachineOverride, Rule,
            RuleScript, GatheredFact, SequenceDefault.
 
            Every parameter is optional. Resolving nothing is a valid, empty
            answer rather than an error - the engine calls this before it
            necessarily knows which sources exist.
 
        .EXAMPLE
            $result = Resolve-HDTVariable -RuleDocument $rules -Fact $fact
            $result.Variable['HDTComputerName']
 
        .EXAMPLE
            Resolve-HDTVariable -CommandLine @{ HDTTaskSequenceID = 'LAB-CLIENT' } `
                -MachineOverride $override.Variable -MachineOverridePath $override.Path `
                -RuleDocument $rules -Fact $fact `
                -SequenceDefault @{ HDTDiskLayout = 'uefi-standard' } `
                -ScriptInvoker (New-HDTScriptInvoker)
 
            All five sources at once, the way the engine calls it in WinPE.
 
        .EXAMPLE
            Get-HDTVariableProvenance -Resolution $result |
                Format-Table Order, Name, Value, Source, Rule
 
            Why every value is what it is - the second half of the answer.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $CommandLine,

        # WHAT A TECHNICIAN TYPED AT THE WIZARD (DESIGN 11.2). It beats the
        # rules and the per-machine override, and loses only to the command
        # line - which was set before the machine booted, so the wizard could
        # not have known about it while the reverse is not true.
        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $Wizard,

        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $MachineOverride,

        [Parameter()]
        [string] $MachineOverridePath,

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

        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $Fact,

        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $SequenceDefault,

        [Parameter()]
        [AllowNull()]
        [object] $ScriptInvoker
    )

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

    $resolution = [pscustomobject] ([ordered] @{
            Variable   = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
            Provenance = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
            Unresolved = New-Object -TypeName System.Collections.ArrayList
        })

    # -- the scope: RAW values, lowest source first so later writes shadow ------

    $scope = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)

    if ($null -ne $SequenceDefault) {
        foreach ($key in @($SequenceDefault.Keys)) {
            $scope[[string] $key] = $SequenceDefault[$key]
        }
    }

    if ($null -ne $Fact) {
        foreach ($key in @($Fact.Keys)) {
            $scope[[string] $key] = $Fact[$key]
        }
    }

    # -- precedence 1: the command line ----------------------------------------

    if ($null -ne $CommandLine) {
        foreach ($key in @($CommandLine.Keys)) {
            $null = Add-HDTResolvedVariable -Resolution $resolution -Scope $scope `
                -Name ([string] $key) -Value $CommandLine[$key] -Source 'CommandLine'
        }
    }

    # -- precedence 1b: what the technician typed at the wizard -----------------
    #
    # AFTER THE COMMAND LINE AND BEFORE EVERYTHING ELSE. A command line was set
    # before this machine booted; the wizard answered a question that was still
    # open after it did, so where both speak the wizard yields - it could not
    # have known about the command line, and the technician who set the command
    # line could not have known what the wizard would ask.
    #
    # AN EMPTY BOX IS NOT AN ANSWER. Collected as '', it would RESOLVE the
    # variable and stop the rule that would have supplied a real one - the same
    # trap Get-HDTWizardSummary refuses to write into a snippet, and the reason
    # a technician who tabbed past a box gets the rule's value rather than
    # nothing at all.
    if ($null -ne $Wizard) {
        foreach ($key in @($Wizard.Keys)) {

            $value = $Wizard[$key]
            if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { continue }
            if ($null -eq $value) { continue }

            $null = Add-HDTResolvedVariable -Resolution $resolution -Scope $scope `
                -Name ([string] $key) -Value $value -Source 'Wizard'
        }
    }

    # -- precedence 2: the per-machine override --------------------------------

    if ($null -ne $MachineOverride) {
        foreach ($key in @($MachineOverride.Keys)) {
            $null = Add-HDTResolvedVariable -Resolution $resolution -Scope $scope `
                -Name ([string] $key) -Value $MachineOverride[$key] -Source 'MachineOverride' -File $MachineOverridePath
        }
    }

    # -- precedence 3: rules.yaml, top to bottom -------------------------------

    if ($null -ne $RuleDocument) {
        foreach ($rule in @($RuleDocument.Rule)) {

            if (-not (Test-HDTRuleMatch -When $rule.When -Scope $scope -Unresolved $resolution.Unresolved)) {
                continue
            }

            $locator = "rule {0} ('{1}')" -f $rule.Index, $rule.Name

            if (-not [string]::IsNullOrWhiteSpace($rule.SetFrom)) {

                if ($null -eq $ScriptInvoker) {
                    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $RuleDocument.Path `
                                -Message ("{0}: setFrom names the script '{1}' but no script invoker was supplied, so the rule cannot be applied. Pass -ScriptInvoker." -f $locator, $rule.SetFrom)))
                }

                # A COPY, so a user script cannot mutate engine state.
                $copy = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
                foreach ($key in @($scope.Keys)) {
                    $copy[[string] $key] = $scope[$key]
                }

                $returned = $null
                $failure = $null
                try {
                    $returned = $ScriptInvoker.Invoke($rule.SetFrom, $copy)
                } catch {
                    $failure = $_
                }

                if ($null -ne $failure) {
                    $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $RuleDocument.Path `
                                -Message ("{0}: the setFrom script '{1}' failed. {2}" -f $locator, $rule.SetFrom, $failure.Exception.Message) `
                                -InnerException $failure.Exception))
                }

                # A script that emits nothing sets nothing, and that is not an error.
                if ($null -eq $returned) {
                    continue
                }

                $emitted = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)

                if ($returned -is [System.Collections.IDictionary]) {
                    foreach ($key in @($returned.Keys)) {
                        $emitted[[string] $key] = $returned[$key]
                    }
                } else {
                    foreach ($property in @($returned.PSObject.Properties)) {
                        $emitted[[string] $property.Name] = $property.Value
                    }
                }

                foreach ($key in @($emitted.Keys)) {
                    $name = [string] $key

                    if ($name.StartsWith('_')) {
                        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $RuleDocument.Path `
                                    -Message ("{0}: the setFrom script '{1}' returned '{2}', which is engine-owned and cannot be assigned. A variable named _HDT* is set by the engine and is read-only." -f $locator, $rule.SetFrom, $name)))
                    }

                    $null = Add-HDTResolvedVariable -Resolution $resolution -Scope $scope `
                        -Name $name -Value $emitted[$key] -Source 'RuleScript' `
                        -Rule $rule.Name -RuleIndex $rule.Index -File $rule.SetFrom
                }

                continue
            }

            foreach ($key in @($rule.Set.Keys)) {
                $null = Add-HDTResolvedVariable -Resolution $resolution -Scope $scope `
                    -Name ([string] $key) -Value $rule.Set[$key] -Source 'Rule' `
                    -Rule $rule.Name -RuleIndex $rule.Index -File $RuleDocument.Path
            }
        }
    }

    # -- precedence 4: the gathered facts --------------------------------------

    if ($null -ne $Fact) {
        foreach ($key in @($Fact.Keys)) {
            $null = Add-HDTResolvedVariable -Resolution $resolution -Scope $scope `
                -Name ([string] $key) -Value $Fact[$key] -Source 'GatheredFact'
        }
    }

    # -- precedence 5: the sequence defaults -----------------------------------

    if ($null -ne $SequenceDefault) {
        foreach ($key in @($SequenceDefault.Keys)) {
            $null = Add-HDTResolvedVariable -Resolution $resolution -Scope $scope `
                -Name ([string] $key) -Value $SequenceDefault[$key] -Source 'SequenceDefault'
        }
    }

    # Ordinal, not Sort-Object: a culture-sensitive sort would order the report
    # differently on a machine with different regional settings.
    $unresolved = [string[]] @($resolution.Unresolved)
    [array]::Sort($unresolved, [System.StringComparer]::Ordinal)
    $resolution.Unresolved = $unresolved

    return $resolution
}