public/Invoke-WoodpeckerPipelineCompiler.ps1

function Invoke-WoodpeckerPipelineCompiler {
    <#
    .SYNOPSIS
        Compiles .woodpecker-src templates into Woodpecker pipeline YAML.
    .DESCRIPTION
        Reads _vars.yml, resolves include() and yamlFile() primary expressions,
        expands Jax mustache placeholders, applies Woodpecker defaults, and
        writes deterministic UTF-8/LF YAML output.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $RepoRoot,

        [string] $SourceDir,

        [string] $OutDir,

        [string] $JaxRoot,

        [switch] $DryRun,

        [string] $SourceDirectoryLabel = '.woodpecker-src',

        [string] $GeneratorPath = 'WoodpeckerTools/Invoke-WoodpeckerPipelineCompiler',

        [string] $ReferencePath = 'https://github.com/serrnovik/woodpecker-tools',

        [string] $TechSpecPath
    )

    Initialize-WpcDependencies -JaxRoot $JaxRoot

    $resolvedRepoRoot = [IO.Path]::GetFullPath($RepoRoot)
    if ([string]::IsNullOrWhiteSpace($SourceDir)) {
        $SourceDir = Join-Path $resolvedRepoRoot '.woodpecker-src'
    }
    if ([string]::IsNullOrWhiteSpace($OutDir)) {
        $OutDir = Join-Path $resolvedRepoRoot '.woodpecker'
    }
    $SourceDir = [IO.Path]::GetFullPath($SourceDir)
    $OutDir = [IO.Path]::GetFullPath($OutDir)

    if (-not (Test-Path -LiteralPath $SourceDir -PathType Container)) {
        throw "Source directory not found: '$SourceDir'"
    }
    if (-not (Test-Path -LiteralPath $OutDir) -and -not $DryRun) {
        New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
    }

    $varsFile = Join-Path $SourceDir '_vars.yml'
    if (-not (Test-Path -LiteralPath $varsFile -PathType Leaf)) {
        throw "_vars.yml not found in '$SourceDir'"
    }
    $globalVars = Read-JaxYaml -Path $varsFile

    $sourceFiles = Get-WpcPipelineSourceFiles -SourceDir $SourceDir
    $anyDiff = $false
    $expectedOutFileNames = [System.Collections.Generic.HashSet[string]]::new(
        [System.StringComparer]::OrdinalIgnoreCase
    )

    foreach ($srcFile in $sourceFiles) {
        $relativeSourcePath = Get-WpcRelativeSourcePath `
            -SourceDir $SourceDir `
            -SourceFilePath $srcFile.FullName
        $outFileName = Convert-WpcRelativeSourcePathToOutputFileName `
            -RelativeSourcePath $relativeSourcePath
        $outFile = Join-Path $OutDir $outFileName
        $null = $expectedOutFileNames.Add($outFileName)

        Write-Host "Compiling: $relativeSourcePath → $outFileName" -ForegroundColor Cyan

        $template = Read-JaxYaml -Path $srcFile.FullName
        $preprocessed = Invoke-WpcPreProcess `
            -node $template `
            -baseVars $globalVars `
            -repoRoot $resolvedRepoRoot `
            -sourceDir $SourceDir

        $expanded = Expand-Placeholders `
            -ht $preprocessed `
            -override $globalVars `
            -ignoreMissingPlaceholders `
            -warningsAsVerbose
        $expanded = Restore-WpcArrayShapes `
            -templateNode $preprocessed `
            -expandedNode $expanded
        $expanded = Add-WpcDefaultCloneSettings -Pipeline $expanded

        $ciVars = $globalVars['vars']['ci']
        if ($ciVars -and $ciVars.Contains('k8s_backend_options')) {
            $ciImageVariants = @(
                $ciVars['main_build_ci_image'],
                $ciVars['ci_builder_release_ci_image'],
                $ciVars['web_build_ci_image'],
                $ciVars['docker_build_ci_image']
            ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }

            $expanded = Add-WpcDefaultKubernetesBackendOptions `
                -Pipeline $expanded `
                -Images $ciImageVariants `
                -BackendOptions $ciVars['k8s_backend_options'] `
                -StepEnvironment $ciVars['builder_environment']
        }

        $serialized = ConvertTo-Yaml -Data $expanded
        $serialized = Repair-WpcSerializedYaml `
            -YamlText $serialized `
            -TemplateNode $preprocessed
        $yamlDocument = if ($serialized.TrimStart().StartsWith('---')) {
            $serialized
        } else {
            "---`n$serialized"
        }
        $generatedHeader = Get-WpcGeneratedFileHeader `
            -SourceFileName $relativeSourcePath `
            -SourceDirectoryLabel $SourceDirectoryLabel `
            -GeneratorPath $GeneratorPath `
            -ReferencePath $ReferencePath `
            -TechSpecPath $TechSpecPath
        $yamlOut = "$generatedHeader`n$yamlDocument"

        if ($DryRun) {
            if (Test-Path -LiteralPath $outFile -PathType Leaf) {
                $existing = (Get-Content -LiteralPath $outFile -Raw) -replace "`r`n", "`n"
                $normalized = $yamlOut -replace "`r`n", "`n"
                if ($existing.TrimEnd() -ne $normalized.TrimEnd()) {
                    Write-Host " DIFF: $outFileName would change" -ForegroundColor Yellow
                    $anyDiff = $true
                } else {
                    Write-Host ' OK (no change)' -ForegroundColor Green
                }
            } else {
                Write-Host " NEW: $outFileName would be created" -ForegroundColor Yellow
                $anyDiff = $true
            }
        } else {
            $lfText = ($yamlOut -replace "`r`n", "`n").TrimEnd("`n") + "`n"
            [IO.File]::WriteAllText(
                $outFile,
                $lfText,
                [Text.UTF8Encoding]::new($false)
            )
            Write-Host " Written: $outFile" -ForegroundColor Green
        }
    }

    if (Test-Path -LiteralPath $OutDir -PathType Container) {
        $staleCompiledFiles = Get-ChildItem -LiteralPath $OutDir -Filter '*.yaml' -File |
            Where-Object { -not $expectedOutFileNames.Contains($_.Name) }

        foreach ($staleFile in $staleCompiledFiles) {
            if ($DryRun) {
                Write-Host " REMOVE: $($staleFile.Name) would be deleted" -ForegroundColor Yellow
                $anyDiff = $true
                continue
            }

            Remove-Item -LiteralPath $staleFile.FullName -Force
            Write-Host " Removed stale output: $($staleFile.FullName)" -ForegroundColor DarkYellow
        }
    }

    if ($DryRun -and $anyDiff) {
        Write-Host ''
        Write-Host 'Compiled pipelines are out of sync.' -ForegroundColor Red
    } elseif (-not $DryRun) {
        Write-Host ''
        Write-Host 'All pipelines compiled successfully.' -ForegroundColor Green
    }

    return [pscustomobject]@{
        InSync = -not $anyDiff
        Changed = $anyDiff
        SourceCount = @($sourceFiles).Count
        SourceDir = $SourceDir
        OutDir = $OutDir
    }
}