Engines/Terraform/Invoke-AvmTerraformLint.ps1

function Invoke-AvmTerraformLint {
    <#
    .SYNOPSIS
        Run 'tflint' against the resolved terraform module root.

    .DESCRIPTION
        Engine implementation called by Invoke-AvmLint when the module
        context is Ecosystem='terraform'. Resolves 'tflint' via
        Resolve-AvmTool, runs '--recursive --format=json' against
        $Context.Root, then parses the JSON 'issues' array into the
        shared Issue record shape used by Invoke-AvmBicepLint.

        tflint exit codes:
          0 - no issues
          2 - issues found (treated as 'fail' only when severity=error)
          1 - tflint itself failed (treated as throw)

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

    .PARAMETER AllowPathFallback
        Pass through to Resolve-AvmTool.

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

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

        [switch] $AllowPathFallback
    )

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

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

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

    # Discover .tf files just for the FilesProcessed count; tflint walks
    # the working directory itself when given --recursive.
    $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)

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

    # exit 0 = no issues; 2 = issues; anything else = tflint itself misbehaved.
    if ($result.ExitCode -ne 0 -and $result.ExitCode -ne 2) {
        $stderr = if ($result.StdErr) { $result.StdErr.Trim() } else { '' }
        $tail = if ($stderr) { ": $stderr" } else { '.' }
        throw [AvmProcessException]::new(
            ('tflint 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 tflint --format=json output: $($_.Exception.Message)")
        }
        if ($parsed -and ($parsed.PSObject.Properties.Name -contains 'issues')) {
            foreach ($issue in @($parsed.issues)) {
                $sev = if ($issue.rule -and $issue.rule.severity) { [string]$issue.rule.severity } else { 'warning' }
                $code = if ($issue.rule -and $issue.rule.name) { [string]$issue.rule.name } else { '' }
                $msg = if ($issue.message) { [string]$issue.message } else { '' }
                $file = ''
                $line = 0
                $col = 0
                if ($issue.range) {
                    if ($issue.range.filename) { $file = [string]$issue.range.filename }
                    if ($issue.range.start) {
                        if ($issue.range.start.line) { $line = [int]$issue.range.start.line }
                        if ($issue.range.start.column) { $col = [int]$issue.range.start.column }
                    }
                }
                $issues.Add([pscustomobject][ordered]@{
                        File     = $file
                        Line     = $line
                        Column   = $col
                        Severity = $sev.ToLowerInvariant()
                        Code     = $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()
    }
}