Public/Get-AvmUpdatePlan.ps1

function Get-AvmUpdatePlan {
    <#
    .SYNOPSIS
        Scans a directory, looks up the latest version for each AVM module, performs risk
        analysis, and returns the set of update candidates.

    .DESCRIPTION
        Combines Get-AvmModuleReference, Get-AvmLatestVersion, and Get-AvmUpdateRisk into
        a single pipeline. Returns only modules where an update is available (updateType != none),
        enriched with risk tier, risk reasons, and the interface diff. Also returns a summary
        count by updateType.

    .PARAMETER Path
        Root directory to scan. Defaults to the current directory.

    .PARAMETER Exclude
        Array of glob patterns to exclude.

    .PARAMETER ConfigPath
        Path to the avmupdater.config.json file.

    .OUTPUTS
        PSCustomObject with: candidates[], summary (counts by updateType and risk tier).

    .EXAMPLE
        Get-AvmUpdatePlan -Path ./infra

    .EXAMPLE
        Get-AvmUpdatePlan -Path . -Exclude @('**/.terraform/**') -ConfigPath ./config/avmupdater.config.json
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Position = 0)]
        [string]$Path = '.',

        [Parameter()]
        [string[]]$Exclude = @(),

        [Parameter()]
        [string]$ConfigPath
    )

    $cfg = Get-AvmConfig -ConfigPath $ConfigPath

    # Build exclude list from args + config
    $allExcludes = @($Exclude) + @($cfg.excludePaths)

    $ignoreModulePatterns = @($cfg.ignoreModules)
    $pinnedVersions       = if ($cfg.pinnedVersions -is [hashtable]) { $cfg.pinnedVersions } else { @{} }

    Write-Verbose "Scanning $Path for AVM references..."
    $refs = @(Get-AvmModuleReference -Path $Path -Exclude $allExcludes)

    Write-Verbose "Found $($refs.Count) AVM references. Looking up versions..."

    $candidates          = [System.Collections.Generic.List[PSCustomObject]]::new()
    $lookupFailed        = [System.Collections.Generic.List[PSCustomObject]]::new()
    $upToDate            = [System.Collections.Generic.List[PSCustomObject]]::new()
    $ignored             = [System.Collections.Generic.List[PSCustomObject]]::new()
    $pinned              = [System.Collections.Generic.List[PSCustomObject]]::new()
    $upToDateByConstraint = [System.Collections.Generic.List[PSCustomObject]]::new()
    $skippedConstraints   = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($ref in $refs) {
        # --- Policy: ignoreModules — skip entirely, no version lookup performed ---
        $ignoreMatch = $ignoreModulePatterns | Where-Object { Test-AvmModuleIdentifierMatch -Reference $ref -Pattern $_ } | Select-Object -First 1
        if ($ignoreMatch) {
            $ignored.Add([PSCustomObject]@{
                ecosystem      = $ref.ecosystem
                module         = $ref.module
                registryPath   = $ref.registryPath
                file           = $ref.file
                lineNumber     = $ref.lineNumber
                currentVersion = $ref.currentVersion
                matchedPattern = $ignoreMatch
                reason         = 'ignored by config (ignoreModules)'
            })
            continue
        }

        $lookup = Get-AvmLatestVersion -RegistryPath $ref.registryPath -Ecosystem $ref.ecosystem -Config $cfg

        if ($lookup.status -eq 'lookup-failed') {
            $lookupFailed.Add([PSCustomObject]@{
                reference    = $ref
                status       = 'lookup-failed'
                riskTier     = 'UNKNOWN'
                riskReasons  = @("Version lookup failed — could not reach registry for $($ref.registryPath)")
            })
            continue
        }

        $latestStable = $lookup.latestStable
        if (-not $latestStable) {
            Write-Warning "No stable version found for $($ref.registryPath)"
            continue
        }

        # --- Policy: pinnedVersions — cap the target at the highest version <= pin ---
        $pinMatchKey = $pinnedVersions.Keys | Where-Object { Test-AvmModuleIdentifierMatch -Reference $ref -Pattern $_ } | Select-Object -First 1
        $pinReason   = $null
        if ($pinMatchKey) {
            $pinValue      = $pinnedVersions[$pinMatchKey]
            $cappedVersion = Get-AvmPinnedVersion -AllVersions $lookup.allVersions -Pin $pinValue

            $pinned.Add([PSCustomObject]@{
                ecosystem      = $ref.ecosystem
                module         = $ref.module
                registryPath   = $ref.registryPath
                file           = $ref.file
                lineNumber     = $ref.lineNumber
                currentVersion = $ref.currentVersion
                pin            = $pinValue
                cappedVersion  = $cappedVersion
                matchedPattern = $pinMatchKey
            })

            if (-not $cappedVersion) {
                Write-Warning "Pinned version '$pinValue' for $($ref.registryPath) has no matching available version — skipping."
                continue
            }

            $latestStable = $cappedVersion
            $pinReason    = "pinned to $pinValue by config"
        }

        # --- Constraint refs (Terraform "~>", ">=, <", etc.) get their own evaluation path ---
        if ($ref.isConstraint) {
            $constraintEval = Test-AvmTerraformConstraint -Constraint $ref.currentVersion -TargetVersion $latestStable

            if (-not $constraintEval.parseable) {
                $skippedConstraints.Add([PSCustomObject]@{
                    ecosystem      = $ref.ecosystem
                    module         = $ref.module
                    registryPath   = $ref.registryPath
                    file           = $ref.file
                    lineNumber     = $ref.lineNumber
                    currentVersion = $ref.currentVersion
                    latestStable   = $latestStable
                    riskReasons    = @("Constraint '$($ref.currentVersion)' could not be parsed — skipped to avoid mangling the version pin. Review and update manually.")
                })
                continue
            }

            if ($constraintEval.satisfied) {
                $upToDateByConstraint.Add([PSCustomObject]@{
                    ecosystem      = $ref.ecosystem
                    module         = $ref.module
                    registryPath   = $ref.registryPath
                    file           = $ref.file
                    lineNumber     = $ref.lineNumber
                    currentVersion = $ref.currentVersion
                    latestStable   = $latestStable
                    status         = 'up-to-date-by-constraint'
                    note           = "Constraint '$($ref.currentVersion)' already permits latest stable '$latestStable' — no file change needed; 'terraform init -upgrade' will pick it up."
                })
                continue
            }

            if (-not $constraintEval.canRewrite) {
                $skippedConstraints.Add([PSCustomObject]@{
                    ecosystem      = $ref.ecosystem
                    module         = $ref.module
                    registryPath   = $ref.registryPath
                    file           = $ref.file
                    lineNumber     = $ref.lineNumber
                    currentVersion = $ref.currentVersion
                    latestStable   = $latestStable
                    riskReasons    = @("Range/complex constraint '$($ref.currentVersion)' does not permit latest stable '$latestStable' — skipped rather than auto-rewritten. Review and update manually.")
                })
                continue
            }

            # Not satisfied, but a safe rewrite could be computed (pessimistic/exact single clause).
            $updateType = Get-AvmUpdateType -Current $constraintEval.baseVersion -Target $latestStable

            # Use a clean semver "currentVersion" (not the raw constraint text) for risk / interface
            # diff / changelog lookups — those build GitHub/registry URLs from currentVersion and a
            # literal '~> 0.3' would break the fetch.
            $effectiveRef = $ref.PSObject.Copy()
            $effectiveRef.currentVersion = $constraintEval.baseVersion

            $risk = Get-AvmUpdateRisk -Reference $effectiveRef -TargetVersion $latestStable
            $riskReasons = @($risk.riskReasons)
            if ($pinReason) { $riskReasons += $pinReason }

            $candidates.Add([PSCustomObject]@{
                reference       = $ref
                ecosystem       = $ref.ecosystem
                module          = $ref.module
                registryPath    = $ref.registryPath
                file            = $ref.file
                lineNumber      = $ref.lineNumber
                currentVersion  = $ref.currentVersion
                latestStable    = $constraintEval.proposedConstraint
                resolvedVersion = $latestStable
                updateType      = $updateType
                isConstraint    = $true
                riskTier        = $risk.riskTier
                riskReasons     = $riskReasons
                interfaceDiff   = $risk.interfaceDiff
                changelogLines  = $risk.changelogLines
                status          = 'pending'
                pinnedTo        = if ($pinReason) { $pinnedVersions[$pinMatchKey] } else { $null }
                note            = "Constraint rewrite: '$($ref.currentVersion)' → '$($constraintEval.proposedConstraint)' (resolves latest stable '$latestStable')."
                versionLineMissing = $ref.versionLineMissing
            })
            continue
        }

        $updateType = Get-AvmUpdateType -Current $ref.currentVersion -Target $latestStable

        if ($updateType -eq 'none') {
            $upToDate.Add([PSCustomObject]@{
                ecosystem      = $ref.ecosystem
                module         = $ref.module
                registryPath   = $ref.registryPath
                file           = $ref.file
                lineNumber     = $ref.lineNumber
                currentVersion = $ref.currentVersion
                latestStable   = $latestStable
                status         = 'up-to-date'
            })
            continue
        }

        # Enrich with risk analysis
        $risk = Get-AvmUpdateRisk -Reference $ref -TargetVersion $latestStable
        $riskReasons = @($risk.riskReasons)
        if ($pinReason) { $riskReasons += $pinReason }

        $candidates.Add([PSCustomObject]@{
            reference       = $ref
            ecosystem       = $ref.ecosystem
            module          = $ref.module
            registryPath    = $ref.registryPath
            file            = $ref.file
            lineNumber      = $ref.lineNumber
            currentVersion  = $ref.currentVersion
            latestStable    = $latestStable
            resolvedVersion = $latestStable
            updateType      = $updateType
            isConstraint    = $ref.isConstraint
            riskTier        = $risk.riskTier
            riskReasons     = $riskReasons
            interfaceDiff   = $risk.interfaceDiff
            changelogLines  = $risk.changelogLines
            status          = 'pending'
            pinnedTo        = if ($pinReason) { $pinnedVersions[$pinMatchKey] } else { $null }
            note            = $null
            versionLineMissing = $ref.versionLineMissing
        })
    }

    # Build summary
    $allItems = @($candidates) + @($lookupFailed)
    $summary = [PSCustomObject]@{
        scannedAt    = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
        pathsScanned = @($Path)
        totalRefs    = $refs.Count
        totalUpdates = $candidates.Count
        totalUpToDate = $upToDate.Count
        byUpdateType = @{
            major      = @($candidates | Where-Object updateType -eq 'major').Count
            minor      = @($candidates | Where-Object updateType -eq 'minor').Count
            patch      = @($candidates | Where-Object updateType -eq 'patch').Count
            constraint = @($candidates | Where-Object isConstraint -eq $true).Count
        }
        byRiskTier = @{
            HIGH    = @($allItems | Where-Object riskTier -eq 'HIGH').Count
            MEDIUM  = @($allItems | Where-Object riskTier -eq 'MEDIUM').Count
            LOW     = @($allItems | Where-Object riskTier -eq 'LOW').Count
            UNKNOWN = @($allItems | Where-Object riskTier -eq 'UNKNOWN').Count
        }
        lookupFailed          = $lookupFailed.Count
        ignored               = $ignored.Count
        pinned                = $pinned.Count
        upToDateByConstraint  = $upToDateByConstraint.Count
        skippedConstraints    = $skippedConstraints.Count
    }

    return [PSCustomObject]@{
        candidates            = $candidates.ToArray()
        upToDate              = $upToDate.ToArray()
        lookupFailed          = $lookupFailed.ToArray()
        ignored               = $ignored.ToArray()
        pinned                = $pinned.ToArray()
        upToDateByConstraint  = $upToDateByConstraint.ToArray()
        skippedConstraints    = $skippedConstraints.ToArray()
        summary               = $summary
    }
}