Public/New-HDTScriptInvoker.ps1

function New-HDTScriptInvoker {
    <#
        .SYNOPSIS
            Creates the real IScriptInvoker adapter, which runs a setFrom: script
            and returns its output object.
 
        .DESCRIPTION
            The one place in HDT that executes a user extension script. When a
            rule needs real logic it calls a script -
            "setFrom: Scripts\Get-ComputerName.ps1" - whose output object becomes
            the variable set. PROJECT constraint 4 keeps that behind an interface
            so the rule engine can be proven without executing anything.
 
            The script is invoked as & $script -Variable $Variable *>&1, and the
            LAST item that is NOT a stream record is returned, so a script that
            traces to the output stream by accident does not corrupt the variable
            set. A script that emits nothing yields $null, which is a different
            fact from a script that does not exist.
 
            EVERYTHING IT WROTE IS KEPT, and GetTranscript() returns it - the
            captured output of the LAST Invoke, replaced on the next one. That is
            A hard requirement: "an existing script that only uses
            Write-Host still lands in the log without modification, since real
            fleets carry years of such scripts". Telling a transcript line from a
            result is a type test, not a guess: an InformationRecord, ErrorRecord,
            WarningRecord, VerboseRecord or DebugRecord is transcript, anything
            else is a candidate result. That is a branch inside an adapter, which
            the "adapters stay dumb" rule tolerates only because the IScriptInvoker
            contract proves it on BOTH implementations.
 
            A relative -Path is resolved against -Root, the workspace root, so
            the same 'Scripts\Get-ComputerName.ps1' written in rules.yaml works
            whether the workspace came from a share or from standalone media.
 
            A script that is not on disk throws System.IO.FileNotFoundException
            naming it. Note that a caller catches that wrapped: an exception
            thrown inside a ScriptMethod reaches the caller as
            MethodInvocationException -> RuntimeException -> the original, so a
            test asserting the type must unwrap to the innermost exception. The
            message survives unwrapped, because MethodInvocationException.Message
            embeds it.
 
            Every invocation is recorded in $Operations, before it can throw,
            exactly as the fakes record (tests/helpers/README.md section 4).
 
            It is a [pscustomobject] carrying ScriptMethod members rather than a
            PowerShell class: classes dot-sourced into the module are the known
            flaky path across -Force re-imports (see 01-03).
 
        .PARAMETER Root
            The workspace root a relative script path is resolved against.
            Defaults to the current location.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with an Invoke
            ScriptMethod. Note that Get-Member -MemberType Method does NOT list a
            ScriptMethod - use -MemberType Method, ScriptMethod.
 
        .EXAMPLE
            $invoker = New-HDTScriptInvoker -Root 'C:\HDTLab\Share'
            $invoker.Invoke('Scripts\Get-ComputerName.ps1', @{ HDTSerialNumber = 'FIXTURE-SERIAL-0001' })
 
            Runs a setFrom: script against the variables resolved so far and
            returns the object it emitted.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds a stateless service adapter object; it changes no state.')]
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $Root = (Get-Location).Path
    )

    $invoker = [pscustomobject] @{
        ServiceName    = 'ScriptInvoker'
        Root           = $Root
        Operations     = [System.Collections.ArrayList]::new()
        LastTranscript = [string[]] @()
    }

    $invoker | Add-Member -MemberType ScriptMethod -Name Record -Value {
        param([string] $Operation, [object[]] $Argument)

        [void] $this.Operations.Add([pscustomobject] @{
                Sequence  = $this.Operations.Count + 1
                Operation = $Operation
                Arguments = $Argument
            })
    }

    $invoker | Add-Member -MemberType ScriptMethod -Name GetOperationName -Value {
        return , ([string[]] @($this.Operations | ForEach-Object { $_.Operation }))
    }

    $invoker | Add-Member -MemberType ScriptMethod -Name ResolvePath -Value {
        param([string] $Path)

        if ([System.IO.Path]::IsPathRooted($Path)) {
            return $Path
        }

        return (Join-Path -Path $this.Root -ChildPath $Path)
    }

    $invoker | Add-Member -MemberType ScriptMethod -Name Invoke -Value {
        param([string] $Path, [System.Collections.IDictionary] $Variable)

        $this.Record('Invoke', @($Path, $Variable))

        $resolved = $this.ResolvePath($Path)
        if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) {
            throw [System.IO.FileNotFoundException]::new("Could not find script '$resolved'.", $resolved)
        }

        # The transcript belongs to the LAST invoke, so it is replaced rather
        # than appended to.
        $this.LastTranscript = [string[]] @()

        $transcript = New-Object -TypeName System.Collections.ArrayList
        $result = $null

        foreach ($item in @(& $resolved -Variable $Variable *>&1)) {
            if (($item -is [System.Management.Automation.InformationRecord]) -or
                ($item -is [System.Management.Automation.ErrorRecord]) -or
                ($item -is [System.Management.Automation.WarningRecord]) -or
                ($item -is [System.Management.Automation.VerboseRecord]) -or
                ($item -is [System.Management.Automation.DebugRecord])) {

                [void] $transcript.Add([string] $item)
                continue
            }

            [void] $transcript.Add(($item | Out-String).Trim())
            $result = $item
        }

        $this.LastTranscript = [string[]] @($transcript)

        return $result
    }

    $invoker | Add-Member -MemberType ScriptMethod -Name GetTranscript -Value {
        # The unary comma is mandatory: a ScriptMethod returning an array
        # collapses a single-element array to a scalar (README F3), and a
        # one-line transcript is the common case.
        return , ([string[]] @($this.LastTranscript))
    }

    return $invoker
}