Commands/PipeScript/Export-PipeScript.ps1

function Export-Pipescript {
    <#
    .Synopsis
        Export PipeScript
    .Description
        Builds a path with PipeScript, which exports the outputted files.
        
        Build Scripts (`*.build.ps1`) will run,
        then all Template Files (`*.ps.*` or `*.ps1.*`) will build.

        Either file can contain a `[ValidatePattern]` or `[ValidateScript]` to make the build conditional.
        
        The condition will be validated against the last git commit (if present).
    .EXAMPLE
        Export-PipeScript # (PipeScript can build in parallel)
    #>

    [Alias('Build-PipeScript','bps','eps','psc')]
    param(
    # One or more input paths. If no -InputPath is provided, will build all scripts beneath the current directory.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('FullName')]
    [string[]]
    $InputPath,

    # If set, will prefer to build in a series, rather than in parallel.
    [switch]
    $Serial,

    # If set, will build in parallel
    [switch]
    $Parallel,

    # The number of files to build in each batch.
    [int]
    $BatchSize = 11,

    # The throttle limit for parallel jobs.
    [int]
    $ThrottleLimit = 7
    )

    begin {
        function AutoRequiresSimple {
            param(
            [Management.Automation.CommandInfo]
            $CommandInfo
            )

            process {
                if (-not $CommandInfo.ScriptBlock) { return }
                $simpleRequirements = 
                    foreach ($requiredModule in $CommandInfo.ScriptBlock.Ast.ScriptRequirements.RequiredModules) {
                        if ($requiredModule.Name -and 
                            (-not $requiredModule.MaximumVersion) -and
                            (-not $requiredModule.RequiredVersion)
                        ) {
                            $requiredModule.Name
                        }
                    }

                if ($simpleRequirements) {
                    if ($env:GITHUB_WORKSPACE) {
                        $ManifestsInWorkspace = Get-ChildItem -Recurse -Filter *.psd1 |
                            Where-Object {
                                $_.Name -match "^(?>$(@(
                                    foreach ($simplyRequires in $simpleRequirements) {
                                        [Regex]::Escape($simplyRequires)
                                    }
                                ) -join '|')).psd1$"

                            } | Select-String "ModuleVersion"

                        $simpleRequirements = @(
                            foreach ($simplyRequires in $simpleRequirements) {
                                if ($ManifestsInWorkspace.Path -match "^$([Regex]::Escape($simplyRequires))\.psd1$") {
                                    $importedRequirement = Import-Module -Path $ManifestsInWorkspace.Path -Global -PassThru
                                    if ($importedRequirement) {
                                        continue
                                    }
                                }
                                $simplyRequires
                            }
                        )
                    }
                    Invoke-PipeScript "require latest $($simpleRequirements)"
                }                
            }
        }

        function Test-PipeScript-Build-Condition {
            param(
            [Management.Automation.CommandInfo]
            $CommandInfo,

            [PSObject]
            $ValidateAgainst
            )

            #region Build Condition
            $ValidateAgainstString = 
                if ($ValidateAgainst.ToString -is [psscriptmethod]) {
                    $ValidateAgainst.ToString()
                } else {
                    "$ValidateAgainst"
                }
            if (-not $ValidateAgainst) {
                $ValidateAgainst = git log -n 1
                
                $ValidateAgainstString = 
                    if ($ValidateAgainst.CommitMessage) {
                        $ValidateAgainst.CommitMessage
                    } else {
                        $ValidateAgainst -join [Environment]::Newline
                    }
            }
                        
            foreach ($commandAttribute in $CommandInfo.ScriptBlock.Attributes)  {
                if ($commandAttribute.RegexPattern) {
                    if ($env:GITHUB_STEP_SUMMARY) {
                        @(
                            "* $($CommandInfo) has a Build Validation Pattern: (``$($commandAttribute.RegexPattern)``)."
                            "* $($CommandInfo) Validating Against: ``$ValidateAgainstString``"
                        ) -join [Environment]::Newline | Out-File -Path $env:GITHUB_STEP_SUMMARY -Append
                    }
                    $validationPattern = [Regex]::new($commandAttribute.RegexPattern, $commandAttribute.Options, '00:00:00.1')
                    if (-not $validationPattern.IsMatch($ValidateAgainstString)) {
                        if ($env:GITHUB_STEP_SUMMARY) {
                            "* 🖐️ Skipping $($CommandInfo) because $ValidateAgainstString did not match ($($commandAttribute.RegexPattern))" | Out-File -Path $env:GITHUB_STEP_SUMMARY -Append
                        }
                        Write-Warning "Skipping $($CommandInfo) : Did not match $($validationPattern)"
                        return $false
                    } 
                }
                elseif ($commandAttribute -is [ValidateScript]) 
                {
                    if ($env:GITHUB_STEP_SUMMARY) {                         
                        @(
                            "* $($CommandInfo) has a Build Validation Script"
                        ) -join [Environment]::Newline |
                                Out-File -Path $env:GITHUB_STEP_SUMMARY -Append
                    }
                    $validationOutput = . $commandAttribute.ScriptBlock $ValidateAgainst
                    if (-not $validationOutput) {
                        if ($env:GITHUB_STEP_SUMMARY) {
                            "* 🖐️ Skipping $($CommandInfo) because $($ValidateAgainstString) did not meet the validation criteria:" | Out-File -Path $env:GITHUB_STEP_SUMMARY -Append
                            
                        }
                        Write-Warning "Skipping $($CommandInfo) : The $ValidateAgainstString was not valid"
                        return $false
                    }
                }
            }
            return $true
            #endregion Build Condition
        }

        filter BuildSingleFile {
            param($buildFile)
            if ((-not $PSBoundParameters['BuildFile']) -and $_) { $buildFile = $_}
            $buildFileInfo = $buildFile.Source -as [IO.FileInfo]
            if (-not $buildFileInfo) { return }
            $TotalInputFileLength += $buildFileInfo.Length

            $buildFileTemplate = $buildFile.Template
            if ($buildFileTemplate -and $buildFile.PipeScriptType -ne 'Template') {
                AutoRequiresSimple -CommandInfo $buildFileTemplate                
                try {
                    Invoke-PipeScript $buildFileTemplate.Source
                } catch {
                    $ex = $_
                    Write-Error -ErrorRecord $ex -TargetObject $buildFileInfo
                    if ($env:GITHUB_WORKSPACE -or ($host.Name -eq 'Default Host')) {
                        $fileAndLine = @(@($ex.ScriptStackTrace -split [Environment]::newLine)[-1] -split ',\s',2)[-1]
                        $file, $line = $fileAndLine -split ':\s\D+\s', 2
                        
                        "::error file=$($buildFile.FullName),line=$line::$($ex.Exception.Message)" | Out-Host
                    }
                }
                if ($alreadyBuilt.Count) {
                    $alreadyBuilt[$buildFileTemplate.Source] = $true
                }
            }            

            $EventsFromThisBuild = Get-Event |
                Where-Object TimeGenerated -gt $ThisBuildStartedAt |
                Where-Object SourceIdentifier -Like '*PipeScript*'            
            AutoRequiresSimple -CommandInfo $buildFile
            if (-not (Test-PipeScript-Build-Condition -CommandInfo $buildFile)) {
                return
            }
            $FileBuildStarted = [datetime]::now
            $buildOutput = 
                try {
                    if ($buildFile.PipeScriptType -match 'BuildScript') {
                        if ($buildFile.ScriptBlock.Ast -and $buildFile.ScriptBlock.Ast.Find({param($ast)
                            if ($ast -isnot [Management.Automation.Language.CommandAst]) { return $false }
                            if ('task' -ne $ast.CommandElements[0]) { return $false }
                            return $true
                        }, $true)) {
                            Invoke-PipeScript "require latest InvokeBuild"
                            Invoke-Build -File $buildFile.Source -Result InvokeBuildResult
                        } else {
                            Invoke-PipeScript $buildFile.Source    
                        }
                    } else {
                        Invoke-PipeScript $buildFile.Source
                    }                    
                } catch {
                    $ex = $_
                    Write-Error -ErrorRecord $ex
                    $filesWithErrors += $buildFile.Source -as [IO.FileInfo]
                    $errorsByFile[$buildFile.Source] = $ex
                }

            $EventsFromFileBuild = Get-Event -SourceIdentifier *PipeScript* |
                Where-Object TimeGenerated -gt $FileBuildStarted |
                Where-Object SourceIdentifier -Like '*PipeScript*'

            if ($buildOutput) {
                
                if ($buildOutput -is [IO.FileInfo]) {
                    $TotalOutputFileLength += $buildOutput.Length
                }
                elseif ($buildOutput -as [IO.FileInfo[]]) {
                    foreach ($_ in $buildOutput) {
                        if ($_.Length) {
                            $TotalOutputFileLength += $_.Length
                        }
                    }
                }

                if ($env:GITHUB_WORKSPACE -or ($host.Name -eq 'Default Host')) {
                    $FileBuildEnded = [DateTime]::now
                    
                    "$($buildFile.Source)" | Out-Host
                    if ($buildOutput -is [Management.Automation.ErrorRecord] -or $buildOutput -is [Exception]) {
                        $buildOutput | Out-Host
                        if ($env:GITHUB_STEP_SUMMARY) {
                            @(
                                "* ❌ ``$($buildFile.Source | Split-Path -Leaf)`` !!!:"
                                '~~~'
                                $($buildOutput | Out-String)
                                '~~~'
                            ) -join [Environment]::newLine| Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY
                        }
                    } else {
                        if ($env:GITHUB_STEP_SUMMARY) {
                            @(
                                "* ✅ ``$($buildFile.Source | Split-Path -Leaf)`` ⋙ $(if ($buildOutput -is [IO.FileInfo]) { $buildOutput.Name })"
                                if ($buildOutput -is [object[]]) {
                                    " * $(@(
                                        foreach ($buildOutObject in $buildOutput) {
                                            $buildOutput.Name
                                        }
                                    ) -join ([Environment]::newLine + ' * '))"

                                }
                            ) -join [Environment]::newLine | Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY
                        }                        
                    }
                    $totalProcessTime = 0 
                    if ($env:ACTIONS_RUNNER_DEBUG) {
                        $timingOfCommands = $EventsFromFileBuild | 
                            Where-Object { $_.MessageData.Command -and $_.MessageData.Duration} |
                            Select-Object -ExpandProperty MessageData | 
                            Group-Object Command |
                            Select-Object -Property @{
                                Name = 'Command'
                                Expression = { $_.Name }
                            }, Count, @{
                                Name= 'Duration'
                                Expression = { 
                                    $totalDuration = 0
                                    foreach ($duration in $_.Group.Duration) { 
                                        $totalDuration += $duration.TotalMilliseconds
                                    }
                                    [timespan]::FromMilliseconds($totalDuration)
                                }
                            } | 
                            Sort-Object Duration -Descending
                        
                        $postProcessMessage = @(
                            
                        foreach ($evt in $completionEvents) {
                            $totalProcessTime += $evt.MessageData.TotalMilliseconds
                            $evt.SourceArgs[0]
                            $evt.MessageData
                        }) -join ' '
                        "Built in $($FileBuildEnded - $FileBuildStarted)" | Out-Host
                        "Commands Run:" | Out-Host
                        $timingOfCommands | Out-Host
                        Get-Event -SourceIdentifier PipeScript.PostProcess.Complete -ErrorAction Ignore | Remove-Event
                    }
                }

                if ($ExecutionContext.SessionState.InvokeCommand.GetCommand('git', 'Alias')) {
                    $lastCommitMessage = ($buildFileInfo | git log -n 1 | Select-Object -ExpandProperty CommitMessage -First 1)
                    $buildOutput |
                        Add-Member NoteProperty CommitMessage $lastCommitMessage -Force
                }
                
                $buildOutput | 
                    Add-Member NoteProperty BuildSourceFile $buildFileInfo -Force -PassThru
            }
        }

        $filesWithErrors = @()
        $errorsByFile = @{}
        $errorsOfUnknownOrigin = @()

        $startThreadJob = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Start-ThreadJob','Cmdlet')

        if ($startThreadJob) {
            $ugitImported = @(Get-Module) -match '^ugit$'
            $Psd1sToImport = @(
                "'$($MyInvocation.MyCommand.Module.Path -replace '\.psm1', '.psd1')'"
                if ($ugitImported) {
                    "'$((Get-Module ugit).Path -replace '\.psm1', '.psd1')'"
                }
            ) -join ","
            $InitializationScript = [scriptblock]::Create("
                Import-Module $Psd1sToImport
                function AutoRequiresSimple {$function:AutoRequiresSimple}
                filter BuildSingleFile {$function:BuildSingleFile}
            "
)
            $ThreadJobScript = {                
                $args | BuildSingleFile
            }
        }
    }

    process {       
        if ($env:GITHUB_WORKSPACE) {
            "::group::Discovering files", "from: $InputPath" | Out-Host
        }
        $filesToBuild = 
            @(if (-not $InputPath) {
                Get-PipeScript -PipeScriptPath $pwd |
                    Where-Object PipeScriptType -Match '(?>Template|BuildScript)' |
                    Sort-Object Order, PipeScriptType, Source
            } else {
                foreach ($inPath in $InputPath) {
                    Get-PipeScript -PipeScriptPath $inPath |
                        Where-Object PipeScriptType -Match '(?>Template|BuildScript)' |
                        Sort-Object Order, PipeScriptType, Source
                }
            })

        if ($env:GITHUB_WORKSPACE) {           
            $filesToBuild.Source -join [Environment]::NewLine | Out-Host           
        }
        
        $buildStarted = [DateTime]::Now
        $alreadyBuilt = [Ordered]@{}
        $filesToBuildCount, $filesToBuildTotal, $filesToBuildID  = 0, $filesToBuild.Length, $(Get-Random)

        if ($env:GITHUB_WORKSPACE) {
            "::group::Building PipeScripts [$FilesToBuildCount / $filesToBuildTotal]" | Out-Host                
        }
        # Keep track of how much is input and output.
        [long]$TotalInputFileLength  = 0 
        [long]$TotalOutputFileLength = 0 
        
        if ($Parallel) {
            $Serial = $false
        } else {
            $serial = $true
        }

        # If we're only building one file, there's no point in parallelization.
        if ($filesToBuild.Length -le 1) { $Serial = $true }

        if (-not $startThreadJob) { continue }
        $buildThreadJobs = [Ordered]@{}         
        $pendingBatch = @()
        foreach ($buildFile in $filesToBuild) {
            $ThisBuildStartedAt = [DateTime]::Now
            Write-Progress "Building PipeScripts [$FilesToBuildCount / $filesToBuildTotal]" "$($buildFile.Source) " -PercentComplete $(
                $FilesToBuildCount++
                $FilesToBuildCount * 100 / $filesToBuildTotal 
            ) -id $filesToBuildID
            
            if (-not $buildFile.Source) { continue }
            if ($alreadyBuilt[$buildFile.Source]) { continue }
            
            if ((-not $Serial) -and $startThreadJob) {
                $pendingBatch += $buildFile
                if ($pendingBatch.Length -ge $BatchSize) {
                    $buildThreadJobs["$pendingBatch"]  = Start-ThreadJob -InitializationScript $InitializationScript -ScriptBlock $ThreadJobScript -ArgumentList $pendingBatch -ThrottleLimit $ThrottleLimit
                    $pendingBatch = @()
                }
                
            } else {
                $buildFile | . BuildSingleFile
            }            
            
            $alreadyBuilt[$buildFile.Source] = $true
        }

        if ($pendingBatch.Length) {
            $buildThreadJobs["$pendingBatch"]  = Start-ThreadJob -InitializationScript $InitializationScript -ScriptBlock $ThreadJobScript -ArgumentList $pendingBatch -ThrottleLimit $ThrottleLimit
            $pendingBatch = @()
        }
        $OriginalJobCount = $buildThreadJobs.Count
        

        while ($buildThreadJobs.Count) {
            $remainingJobCount = ($OriginalJobCount - $buildThreadJobs.Count)
            Write-Progress "Waiting for Builds [$remainingJobCount / $originalJobCount]" " " -PercentComplete $(                
                ($OriginalJobCount - $buildThreadJobs.Count) * 100 / $OriginalJobCount
            ) -id $filesToBuildID
            $completedBuilds = @(foreach ($threadKeyValue in $buildThreadJobs.GetEnumerator()) {
                if ($threadKeyValue.Value.State -in 'Completed','Failed','Stopped') {
                    $threadKeyValue
                }
            })
            if (-not $completedBuilds) {
                Start-Sleep -Milliseconds 7
                continue
            }

            foreach ($completedBuild in $completedBuilds) {

                
                $completedBuildOutput = $completedBuild.Value | Receive-Job *>&1
                

                if ($env:GITHUB_WORKSPACE -or ($host.Name -eq 'Default Host')) {
                    $completedBuildOutput | Out-Host
                }                     
                $sourceFilesFromJob = @(foreach ($buildOutput in $completedBuildOutput) {
                    
                    if ($buildOutput -is [IO.FileInfo]) {
                        $TotalOutputFileLength += $buildOutput.Length
                        if ($buildOutput.BuildSourceFile) {
                            $buildOutput.BuildSourceFile
                        }
                    }
                    elseif ($buildOutput -as [IO.FileInfo[]]) {
                        foreach ($_ in $buildOutput) {
                            if ($_.Length) {
                                $TotalOutputFileLength += $_.Length
                                if ($_.BuildSourceFile) {
                                    $_.BuildSourceFile
                                }
                            }
                        }
                    }
                    elseif ($buildOutput -is [Management.Automation.ErrorRecord]) {
                        $buildSourceFile = $buildOutput.TargetObject
                        if ($buildSourceFile -is [IO.FileInfo]) {
                            if (-not $errorsByFile[$buildSourceFile]) {
                                $errorsByFile[$buildSourceFile] = @()
                            }
                            $errorsByFile[$buildSourceFile] += $buildOutput
                            $buildSourceFile
                        } else {
                            $errorsOfUnknownOrigin += $buildOutput
                        }                        
                    }
                })
                
                foreach ($buildSourceFile in $sourceFilesFromJob) {
                    $TotalInputFileLength += $buildSourceFile.Length
                    if ($errorsByFile[$buildSourceFile]) {
                        $filesWithErrors += $buildSourceFile
                    }
                }
                
                foreach ($buildOutput in $completedBuildOutput) {
                    if ($buildOutput -is [IO.FileInfo]) {
                        $buildOutput
                    }
                }
                $buildThreadJobs.Remove($completedBuild.Key)
            }            
        }
        
        $BuildTime = [DateTime]::Now - $buildStarted
        if ($env:GITHUB_WORKSPACE -or ($host.Name -eq 'Default Host')) {
            "$filesToBuildTotal in $($BuildTime)" | Out-Host
            "::endgroup::Building PipeScripts [$FilesToBuildCount / $filesToBuildTotal] : $($buildFile.Source)" | Out-Host
            if ($TotalInputFileLength) {
                $kbIn  = [Math]::Round($TotalInputFileLength / 1kb)
                $kbOut = [Math]::Round($TotalOutputFileLength / 1kb)
                $pipeScriptFactor  = [Math]::round([double]$TotalOutputFileLength/[double]$TotalInputFileLength,4)
                
                if ($env:GITHUB_STEP_SUMMARY) {
                    "> ${kbOut}kb Output / ${kbIn}kb Input = $pipeScriptFactor PipeScript Factor" |
                        Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY
                }
                
                "$([Math]::Round($TotalInputFileLength / 1kb)) kb input"
                "$([Math]::Round($TotalOutputFileLength / 1kb)) kb output",
                "PipeScript Factor: X$([Math]::round([double]$TotalOutputFileLength/[double]$TotalInputFileLength,4))"
            }            
        }
        
        if ($filesWithErrors) {
            "$($filesWithErrors.Length) files with Errors" | Out-Host
            foreach ($fileWithError in $filesWithErrors) {
                "$fileWithError : $($errorsByFile[$fileWithError.FullName] | Out-String)"| Out-Host
            }
        }

        if ($errorsOfUnknownOrigin) {
            "$($errorsOfUnknownOrigin) errors of unknown origin" | Out-Host            
            $errorsOfUnknownOrigin| Out-Host            
        }

        
        Write-Progress "Building PipeScripts [$FilesToBuildCount / $filesToBuildTotal]" "Finished In $($BuildTime) " -Completed -id $filesToBuildID
    }
}