Engines/Terraform/Invoke-AvmTerraformTest.ps1

function Invoke-AvmTerraformTest {
    <#
    .SYNOPSIS
        Run 'terraform validate -json' against the resolved module root.

    .DESCRIPTION
        Engine implementation called by Invoke-AvmTest when the module
        context is Ecosystem='terraform'. Resolves the 'terraform' binary
        via Resolve-AvmTool, then:

          1. If the module root has no '.terraform/' directory and -NoInit
             was not passed, runs
                terraform init -backend=false -upgrade=false -input=false
             so 'validate' can resolve provider requirements without
             needing real backend credentials.
          2. Runs 'terraform validate -no-color -json' against the
             working directory.
          3. Parses the JSON 'diagnostics' array into the shared Issue
             shape used by other engines.

        Auto-init can be skipped with -NoInit; callers running inside a
        pre-initialised module (or who have already run 'terraform init'
        themselves) can pass that switch through Invoke-AvmTest.

    .PARAMETER Context
        Module context produced by Get-AvmModuleContext. Must have
        Ecosystem='terraform'.

    .PARAMETER AllowPathFallback
        Pass through to Resolve-AvmTool.

    .PARAMETER NoInit
        Skip the implicit 'terraform init' even when '.terraform/' is
        missing. Use when init is genuinely impossible (offline + no
        cached providers) or when the caller has already run it.

    .OUTPUTS
        pscustomobject with Engine, Tool, ToolPath, ToolSource, Status,
        FilesProcessed, Issues.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        $Context,

        [switch] $AllowPathFallback,

        [switch] $NoInit
    )

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

    if ($Context.Ecosystem -ne 'terraform') {
        throw [System.ArgumentException]::new(
            "Invoke-AvmTerraformTest requires a terraform context (got Ecosystem='$($Context.Ecosystem)').")
    }

    $tool = Resolve-AvmTool -Name 'terraform' -AllowPathFallback:$AllowPathFallback

    $discovered = Get-ChildItem -LiteralPath $Context.Root -Recurse -File -Filter '*.tf' -ErrorAction SilentlyContinue |
        Where-Object {
            $rel = [System.IO.Path]::GetRelativePath($Context.Root, $_.FullName)
            $parts = $rel -split '[\\/]'
            -not ($parts | Where-Object { $_.StartsWith('.') -or $_ -eq 'node_modules' })
        }
    $files = @($discovered)

    $terraformDir = Join-Path $Context.Root '.terraform'
    if (-not $NoInit -and -not (Test-Path -LiteralPath $terraformDir)) {
        $initResult = Invoke-AvmProcess `
            -FilePath $tool.Path `
            -ArgumentList @('init', '-backend=false', '-upgrade=false', '-input=false', '-no-color') `
            -WorkingDirectory $Context.Root `
            -IgnoreExitCode

        if ($initResult.ExitCode -ne 0) {
            $detail = if ($initResult.StdErr) { $initResult.StdErr.Trim() } else { $initResult.StdOut.Trim() }
            throw [AvmProcessException]::new(
                ('terraform init failed with exit code {0}: {1}' -f $initResult.ExitCode, $detail))
        }
    }

    $result = Invoke-AvmProcess `
        -FilePath $tool.Path `
        -ArgumentList @('validate', '-no-color', '-json') `
        -WorkingDirectory $Context.Root `
        -IgnoreExitCode

    # terraform validate exit codes: 0 = no errors, 1 = errors / config invalid.
    # Anything else is a terraform-internal failure -> rethrow.
    if ($result.ExitCode -ne 0 -and $result.ExitCode -ne 1) {
        $stderr = if ($result.StdErr) { $result.StdErr.Trim() } else { '' }
        $tail = if ($stderr) { ": $stderr" } else { '.' }
        throw [AvmProcessException]::new(
            ('terraform validate exited with code {0}{1}' -f $result.ExitCode, $tail))
    }

    $issues = New-Object System.Collections.Generic.List[object]
    $payload = if ($result.StdOut) { $result.StdOut.Trim() } else { '' }
    if ($payload) {
        try {
            $parsed = $payload | ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            throw [AvmProcessException]::new(
                "Could not parse terraform validate -json output: $($_.Exception.Message)")
        }
        if ($parsed -and ($parsed.PSObject.Properties.Name -contains 'diagnostics')) {
            foreach ($diag in @($parsed.diagnostics)) {
                $sev = if ($diag.severity) { [string]$diag.severity } else { 'warning' }
                $summary = if ($diag.summary) { [string]$diag.summary } else { '' }
                $detail = if ($diag.detail) { [string]$diag.detail } else { '' }
                $msg = if ($detail) { "$summary - $detail" } else { $summary }
                $file = ''
                $line = 0
                $col = 0
                if ($diag.PSObject.Properties.Name -contains 'range' -and $diag.range) {
                    if ($diag.range.filename) { $file = [string]$diag.range.filename }
                    if ($diag.range.start) {
                        if ($diag.range.start.line) { $line = [int]$diag.range.start.line }
                        if ($diag.range.start.column) { $col = [int]$diag.range.start.column }
                    }
                }
                $issues.Add([pscustomobject][ordered]@{
                        File     = $file
                        Line     = $line
                        Column   = $col
                        Severity = $sev.ToLowerInvariant()
                        Code     = ''
                        Message  = $msg
                    })
            }
        }
    }

    $status = if ($issues | Where-Object { $_.Severity -eq 'error' }) { 'fail' } else { 'pass' }

    return [pscustomobject][ordered]@{
        Engine         = 'terraform'
        Tool           = ('{0}/{1}' -f $tool.Name, $tool.Version)
        ToolPath       = $tool.Path
        ToolSource     = $tool.Source
        Status         = $status
        FilesProcessed = $files.Count
        Issues         = $issues.ToArray()
    }
}