Engines/Terraform/Invoke-AvmTerraformLint.ps1

function Resolve-AvmTflintConfigDir {
    <#
    .SYNOPSIS
        Resolve the directory holding the vendored AVM tflint configs.

    .DESCRIPTION
        Returns the absolute path to the directory that ships the three AVM
        tflint rulesets ('avm.tflint.hcl', 'avm.tflint_module.hcl',
        'avm.tflint_example.hcl'). Resolution order:

          1. $env:AVM_TFLINT_CONFIG_DIR - explicit override (test injection and
             power users pointing at a locally-checked-out governance copy).
          2. <ModuleRoot>/Resources/tflint - the configs vendored inside the
             module, kept byte-for-byte in sync with the governance
             tflint-configs/ folder.

        The chosen candidate must be a directory containing all three files.
        Throws AvmConfigurationException when none resolve, so the lint engine
        surfaces a clear package-integrity error rather than linting with no
        AVM rules.

    .OUTPUTS
        [string] absolute path to the resolved config directory.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

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

    $required = @('avm.tflint.hcl', 'avm.tflint_module.hcl', 'avm.tflint_example.hcl')

    $candidates = New-Object System.Collections.Generic.List[string]
    if ($env:AVM_TFLINT_CONFIG_DIR) {
        $candidates.Add($env:AVM_TFLINT_CONFIG_DIR)
    }
    $moduleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
    $candidates.Add((Join-Path $moduleRoot (Join-Path 'Resources' 'tflint')))

    foreach ($candidate in $candidates) {
        if (-not $candidate) { continue }
        if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { continue }
        $present = $true
        foreach ($file in $required) {
            if (-not (Test-Path -LiteralPath (Join-Path $candidate $file) -PathType Leaf)) {
                $present = $false
                break
            }
        }
        if ($present) {
            return (Resolve-Path -LiteralPath $candidate).ProviderPath
        }
    }

    throw [AvmConfigurationException]::new(
        ("Cannot resolve the AVM tflint config bundle (looked in: {0}). " -f ($candidates -join '; ')) +
        'Set the AVM_TFLINT_CONFIG_DIR environment variable or reinstall Avm.Authoring so Resources/tflint is present.')
}

function Get-AvmTflintScope {
    <#
    .SYNOPSIS
        Build the ordered list of directories tflint should lint, each paired
        with the AVM ruleset that applies to it.

    .DESCRIPTION
        The AVM tflint rulesets are directory-specific: the repository root and
        every nested module use the strict rulesets, while examples use a
        relaxed ruleset (interface/docs rules disabled). A single recursive
        tflint invocation cannot express that, so the lint engine runs tflint
        once per scope with the matching '--config'.

        Scope order (deterministic):
          1. the repository root -> avm.tflint.hcl
          2. each direct modules/* dir -> avm.tflint_module.hcl (sorted by name)
          3. each direct examples/* dir -> avm.tflint_example.hcl (sorted by name)

        modules/ and examples/ are enumerated one level deep (matching the
        governance 'foreachdirectory depth:1' behaviour) and skipped entirely
        when absent.

    .PARAMETER Root
        The Terraform repository root.

    .PARAMETER ConfigDir
        The directory returned by Resolve-AvmTflintConfigDir.

    .OUTPUTS
        [object[]] of hashtables with keys Dir (absolute), Config (absolute),
        Label, and RelPath ('.' for the root).
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [string] $Root,

        [Parameter(Mandatory)]
        [string] $ConfigDir
    )

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

    $rootFull = (Resolve-Path -LiteralPath $Root).ProviderPath
    $scopes = New-Object System.Collections.Generic.List[object]

    $scopes.Add(@{
            Dir     = $rootFull
            Config  = (Join-Path $ConfigDir 'avm.tflint.hcl')
            Label   = 'root'
            RelPath = '.'
        })

    $groups = @(
        @{ Name = 'modules'; Config = 'avm.tflint_module.hcl' }
        @{ Name = 'examples'; Config = 'avm.tflint_example.hcl' }
    )
    foreach ($group in $groups) {
        $groupDir = Join-Path $rootFull $group.Name
        if (-not (Test-Path -LiteralPath $groupDir -PathType Container)) { continue }
        $children = @(Get-ChildItem -LiteralPath $groupDir -Directory -ErrorAction SilentlyContinue | Sort-Object Name)
        foreach ($child in $children) {
            $scopes.Add(@{
                    Dir     = $child.FullName
                    Config  = (Join-Path $ConfigDir $group.Config)
                    Label   = ('{0}/{1}' -f $group.Name, $child.Name)
                    RelPath = ('{0}/{1}' -f $group.Name, $child.Name)
                })
        }
    }

    return $scopes.ToArray()
}

function Invoke-AvmTerraformLint {
    <#
    .SYNOPSIS
        Run the AVM tflint rulesets against a Terraform repository, one scope
        at a time, and fail on warnings by default.

    .DESCRIPTION
        Engine implementation called by Invoke-AvmLint when the module context
        is Ecosystem='terraform'. Resolves 'tflint' via Resolve-AvmTool and the
        vendored AVM rulesets via Resolve-AvmTflintConfigDir, then, for every
        scope produced by Get-AvmTflintScope (repository root, each modules/*,
        each examples/*):

            tflint --init --config <absolute ruleset> (install plugins)
            tflint --config <absolute ruleset> --format=json \
                   --minimum-failure-severity=<threshold> (lint)

        This mirrors the upstream avm-terraform-governance pre-check flow, which
        applies the strict rulesets to the root and modules and the relaxed
        ruleset to examples. A single recursive invocation with no '--config'
        (the previous behaviour) applied none of the AVM rules and could not
        express the per-directory rulesets.

        The failure threshold defaults to 'warning', so any warning-severity
        rule fails the gauntlet - most built-in tflint rules are warnings, so an
        'error'-only threshold reported false confidence. The threshold is
        passed to tflint (via --minimum-failure-severity, driving its exit code)
        and used to compute Status from the parsed issues, so both agree. All
        issues are still parsed and returned for reporting regardless of the
        threshold.

        tflint exit codes for the lint call:
          0 - no issues at or above the threshold
          2 - issues found (parsed; drives Status via the threshold)
          other - tflint itself failed (throws AvmProcessException)

        Note: unlike the governance porch flow, this engine does not run
        'terraform init' before tflint. The vendored AVM rulesets are satisfied
        by tflint's plugin '--init' alone; keeping lint free of a terraform
        backend/network round-trip makes 'avm lint' fast and offline-friendly.

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

    .PARAMETER AllowPathFallback
        Pass through to Resolve-AvmTool.

    .PARAMETER MinimumFailureSeverity
        The lowest tflint severity that fails the run. One of 'error',
        'warning' (default), or 'notice'.

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

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

        [switch] $AllowPathFallback,

        [ValidateSet('error', 'warning', 'notice')]
        [string] $MinimumFailureSeverity = 'warning'
    )

    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
    $configDir = Resolve-AvmTflintConfigDir
    $scopes = Get-AvmTflintScope -Root $Context.Root -ConfigDir $configDir

    # Severities at or above the threshold fail the run. tflint emits lowercase
    # 'error' / 'warning' / 'notice'.
    $failSeverities = switch ($MinimumFailureSeverity) {
        'error' { @('error') }
        'warning' { @('error', 'warning') }
        'notice' { @('error', 'warning', 'notice') }
    }

    $issues = New-Object System.Collections.Generic.List[object]
    $filesProcessed = 0

    foreach ($scope in $scopes) {
        # Count only the top-level '*.tf' in this scope - tflint is invoked
        # non-recursively per scope, and nested modules/examples are their own
        # scopes, so this does not double-count.
        $filesProcessed += @(Get-ChildItem -LiteralPath $scope.Dir -File -Filter '*.tf' -ErrorAction SilentlyContinue).Count

        # Install the plugins the ruleset declares (terraform + avm). Idempotent
        # and cached under the shared tflint plugin dir, so repeat scopes are
        # cheap. A non-zero exit here means plugin acquisition failed outright.
        $init = Invoke-AvmProcess `
            -FilePath $tool.Path `
            -ArgumentList @('--init', '--config', $scope.Config) `
            -WorkingDirectory $scope.Dir `
            -IgnoreExitCode
        if ($init.ExitCode -ne 0) {
            $stderr = if ($init.StdErr) { $init.StdErr.Trim() } else { '' }
            $tail = if ($stderr) { ": $stderr" } else { '.' }
            throw [AvmProcessException]::new(
                ("tflint --init for scope '{0}' exited with code {1}{2}" -f $scope.Label, $init.ExitCode, $tail))
        }

        $lintArgs = @(
            '--config', $scope.Config,
            '--format=json',
            ('--minimum-failure-severity={0}' -f $MinimumFailureSeverity)
        )

        $run = Invoke-AvmProcess `
            -FilePath $tool.Path `
            -ArgumentList $lintArgs `
            -WorkingDirectory $scope.Dir `
            -IgnoreExitCode

        # exit 0 = clean; 2 = issues found; anything else = tflint misbehaved.
        if ($run.ExitCode -ne 0 -and $run.ExitCode -ne 2) {
            $stderr = if ($run.StdErr) { $run.StdErr.Trim() } else { '' }
            $tail = if ($stderr) { ": $stderr" } else { '.' }
            throw [AvmProcessException]::new(
                ("tflint for scope '{0}' exited with code {1}{2}" -f $scope.Label, $run.ExitCode, $tail))
        }

        $payload = if ($run.StdOut) { $run.StdOut.Trim() } else { '' }
        if (-not $payload) { continue }

        try {
            $parsed = $payload | ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            throw [AvmProcessException]::new(
                ("Could not parse tflint --format=json output for scope '{0}': {1}" -f $scope.Label, $_.Exception.Message))
        }

        if (-not ($parsed -and ($parsed.PSObject.Properties.Name -contains 'issues'))) { continue }

        foreach ($issue in @($parsed.issues)) {
            $sev = if ($issue.rule -and $issue.rule.severity) { ([string]$issue.rule.severity).ToLowerInvariant() } 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 }
                }
            }
            # Tag the file with its scope so root/module/example issues are
            # distinguishable; tflint reports filenames relative to its own
            # working directory.
            if ($scope.RelPath -ne '.' -and $file) {
                $file = ('{0}/{1}' -f $scope.RelPath, $file)
            }
            $file = $file -replace '\\', '/'

            $issues.Add([pscustomobject][ordered]@{
                    File     = $file
                    Line     = $line
                    Column   = $col
                    Severity = $sev
                    Code     = $code
                    Message  = $msg
                    Scope    = $scope.Label
                })
        }
    }

    $status = if ($issues | Where-Object { $failSeverities -contains $_.Severity }) { '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 = $filesProcessed
        Issues         = $issues.ToArray()
    }
}