private/Invoke-BuildPlan.ps1

function Invoke-BuildPlan {
    <#
    .SYNOPSIS
    Executes a compiled build plan.

    .DESCRIPTION
    Takes a PsakeBuildPlan and executes tasks in the pre-computed order,
    with caching, setup/teardown hooks, and structured result collection.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [PsakeBuildPlan]$Plan,

        [switch]$NoCache,

        [Parameter(Mandatory = $true)]
        $Module,

        [Parameter(Mandatory = $true)]
        $CurrentContext,

        [hashtable]$Parameters = @{},

        [hashtable]$Properties = @{},

        [scriptblock]$Initialization = {}
    )

    Write-Debug "Executing build plan for '$($Plan.BuildFile)' with $($Plan.ExecutionOrder.Count) tasks"
    Write-Debug "Execution order: $($Plan.ExecutionOrder -join ' -> ')"
    Write-Debug "NoCache=$NoCache"

    # Build reverse-dependency map: taskKey -> list of taskKeys that directly depend on it
    $parentMap = @{}
    foreach ($taskKey in $Plan.ExecutionOrder) {
        if (-not $parentMap.ContainsKey($taskKey)) { $parentMap[$taskKey] = @() }
        $task = $Plan.TaskMap[$taskKey]
        foreach ($dep in $task.DependsOn) {
            $depKey = $dep.ToLower()
            if (-not $parentMap.ContainsKey($depKey)) { $parentMap[$depKey] = @() }
            $parentMap[$depKey] += $taskKey
        }
    }

    $failedTasks = @{}

    $buildResult = [PsakeBuildResult]::new()
    $buildResult.BuildFile = $Plan.BuildFile
    $buildResult.StartedAt = [datetime]::UtcNow
    $buildResult.Success = $true

    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    try {
        # Inject parameters
        foreach ($key in $Parameters.Keys) {
            $variableSplat = @{
                Value   = $Parameters.$key
                WhatIf  = $false
                Confirm = $false
            }
            if (Test-Path "variable:\$key") {
                $null = Set-Variable @variableSplat -Name $key
            } else {
                $null = New-Item @variableSplat -Path "variable:\$key"
            }
        }

        # Execute property blocks
        while ($CurrentContext.properties.Count -gt 0) {
            $propertyBlock = $CurrentContext.properties.Pop()
            $null = . $propertyBlock
        }

        # Inject command-line properties (override)
        foreach ($key in $Properties.Keys) {
            if (Test-Path "variable:\$key") {
                $null = Set-Variable -Name $key -Value $Properties.$key -WhatIf:$false -Confirm:$false
            }
        }

        # Run initialization
        $null = . $Module $Initialization

        # Run build setup
        $null = & $CurrentContext.buildSetupScriptBlock

        try {
            # Execute tasks in plan order
            foreach ($taskKey in $Plan.ExecutionOrder) {
                $task = $Plan.TaskMap[$taskKey]
                $taskResult = [PsakeTaskResult]::new()
                $taskResult.Name = $task.Name

                Write-Debug "Processing task '$taskKey'"

                # Check if any dependency failed
                $failedDep = $task.DependsOn | Where-Object { $failedTasks.ContainsKey($_.ToLower()) } | Select-Object -First 1
                if ($failedDep) {
                    if ($task.ContinueOnError) {
                        # Failure absorbed — do not propagate to this task's own dependents
                        Write-BuildMessage ("-" * 70)
                        Write-BuildMessage ($msgs.continue_on_error -f $task.Name, "dependency '$failedDep' failed") "warning"
                        Write-BuildMessage ("-" * 70)
                        $taskResult.Status = 'Skipped'
                        $taskResult.Duration = [System.TimeSpan]::Zero
                        $buildResult.Tasks += $taskResult
                        $CurrentContext.executedTasks.Push($taskKey)
                        continue
                    } else {
                        throw "Task '$($task.Name)' cannot run because dependency '$failedDep' failed."
                    }
                }

                if ($taskKey -eq 'default') {
                    $taskResult.Status = 'Skipped'
                    $taskResult.Duration = [System.TimeSpan]::Zero
                    $buildResult.Tasks += $taskResult
                    $CurrentContext.executedTasks.Push($taskKey)
                    continue
                }

                $precondition_is_valid = & $task.PreCondition
                if (-not $precondition_is_valid) {
                    Write-BuildMessage ($msgs.precondition_was_false -f $task.Name) "heading"
                    $taskResult.Status = 'Skipped'
                    $taskResult.Duration = [System.TimeSpan]::Zero
                    $buildResult.Tasks += $taskResult
                    $CurrentContext.executedTasks.Push($taskKey)
                    continue
                }

                # Check cache for tasks with inputs (cache miss does not
                # necessarily mean the task will be executed, as preconditions
                # may still prevent execution)
                if (-not $NoCache -and $null -ne $task.Inputs) {
                    if (Test-TaskCache -Task $task -Plan $Plan) {
                        $task.Cached = $true
                        $taskResult.Status = 'Cached'
                        $taskResult.Cached = $true
                        $taskResult.Duration = [System.TimeSpan]::Zero
                        $taskResult.InputHash = $task.InputHash
                        $buildResult.Tasks += $taskResult
                        $CurrentContext.executedTasks.Push($taskKey)
                        Write-BuildMessage ($msgs.task_cached -f $task.Name) "heading"
                        continue
                    }
                }

                if ($task.PreAction -or $task.PostAction) {
                    Assert ($null -ne $task.Action) ($msgs.error_missing_action_parameter -f $task.Name)
                }

                foreach ($variable in $task.RequiredVariables) {
                    Assert ((Test-Path "variable:$variable") -and ($null -ne (Get-Variable $variable).Value)) ($msgs.required_variable_not_set -f $variable, $task.Name)
                }

                if ($task.Action) {
                    $taskStopwatch = [System.Diagnostics.Stopwatch]::new()

                    try {
                        $taskStopwatch.Start()
                        $CurrentContext.currentTaskName = $task.Name

                        try {
                            $null = & $CurrentContext.taskSetupScriptBlock @($task)
                            try {
                                if ($task.PreAction) {
                                    $null = & $task.PreAction
                                }

                                if ($CurrentContext.config.taskNameFormat -is [ScriptBlock]) {
                                    $taskHeader = & $CurrentContext.config.taskNameFormat $task.Name
                                } else {
                                    $taskHeader = $CurrentContext.config.taskNameFormat -f $task.Name
                                }
                                Write-BuildMessage $taskHeader "heading"

                                $null = & $task.Action
                            } finally {
                                if ($task.PostAction) {
                                    $null = & $task.PostAction
                                }
                            }
                        } catch {
                            $task.Success = $false
                            $task.ErrorMessage = $_
                            $task.ErrorDetail = $_ | Out-String
                            $task.ErrorFormatted = Format-ErrorMessage $_
                            $task.ErrorRecord = $_
                            throw $_
                        } finally {
                            $null = & $CurrentContext.taskTearDownScriptBlock $task
                        }
                    } catch {
                        if ($task.ContinueOnError) {
                            # Failure absorbed — do not propagate to this task's own dependents
                            Write-BuildMessage ("-" * 70)
                            Write-BuildMessage ($msgs.continue_on_error -f $task.Name, $_) "warning"
                            Write-BuildMessage ("-" * 70)
                            $taskResult.Status = 'Failed'
                            $taskResult.ErrorMessage = $_.ToString()
                        } else {
                            # Check if a direct parent has ContinueOnError — if so, this failure
                            # is absorbed by the parent (matches old recursive execution behaviour)
                            $absorbingParent = $parentMap[$taskKey] |
                                Where-Object { $Plan.TaskMap[$_].ContinueOnError } |
                                Select-Object -First 1
                            if ($absorbingParent) {
                                Write-BuildMessage ("-" * 70)
                                Write-BuildMessage ($msgs.continue_on_error -f $Plan.TaskMap[$absorbingParent].Name, $_) "warning"
                                Write-BuildMessage ("-" * 70)
                                $taskResult.Status = 'Failed'
                                $taskResult.ErrorMessage = $_.ToString()
                                $failedTasks[$taskKey] = $true
                            } else {
                                $taskResult.Status = 'Failed'
                                $taskResult.ErrorMessage = $_.ToString()
                                $task.Duration = $taskStopwatch.Elapsed
                                $taskResult.Duration = $task.Duration
                                $buildResult.Tasks += $taskResult
                                throw $_
                            }
                        }
                    } finally {
                        $task.Duration = $taskStopwatch.Elapsed
                    }


                    Write-Debug "Task '$($task.Name)' completed in $($task.Duration)"
                    $task.Executed = $true
                    if ($taskResult.Status -ne 'Failed') {
                        $taskResult.Status = 'Executed'
                    }
                    $taskResult.Duration = $task.Duration

                    # Update cache after successful execution
                    if ($null -ne $task.Inputs -and $task.Success) {
                        Update-TaskCache -Task $task -Plan $Plan
                    }
                } else {
                    $taskResult.Status = 'Skipped'
                    $taskResult.Duration = [System.TimeSpan]::Zero
                }

                Assert (& $task.PostCondition) ($msgs.postcondition_failed -f $task.Name)

                $CurrentContext.executedTasks.Push($taskKey)
                $buildResult.Tasks += $taskResult
            }
        } finally {
            $null = & $CurrentContext.buildTearDownScriptBlock
        }

    } catch {
        $buildResult.Success = $false
        $buildResult.ErrorMessage = Format-ErrorMessage $_
        $buildResult.ErrorRecord = $_
        throw $_
    } finally {
        $stopwatch.Stop()
        $buildResult.Duration = $stopwatch.Elapsed
        $buildResult.CompletedAt = [datetime]::UtcNow
    }

    return $buildResult
}