Public/New-AvmUpdateReport.ps1

function New-AvmUpdateReport {
    <#
    .SYNOPSIS
        Generates a Markdown overview and a JSON manifest from an AvmUpdater update plan.

    .DESCRIPTION
        Writes two files to the output directory:
        - avm-update-report.md — human-readable overview with summary table, per-update detail
          for MEDIUM/HIGH, interface diff, changelog evidence, and separate sections for LOW,
          needs-review, and could-not-check modules.
        - avm-update-plan.json — machine-readable full plan (consumed by the updater).
        Pure function: no network calls.

    .PARAMETER Plan
        The enriched plan object returned by Get-AvmUpdatePlan.

    .PARAMETER OutputDirectory
        Directory where report files are written. Defaults to './avm-report'.

    .PARAMETER Items
        Optional subset of $Plan.candidates to report on (e.g. only the LOW-risk items going
        into a split PR). When supplied, the report is scoped to these items only — lookup
        failures from the full plan are omitted since they don't belong to any specific split.
        When omitted, the full $Plan.candidates/$Plan.lookupFailed are reported (default
        behavior, unchanged).

    .OUTPUTS
        PSCustomObject with: reportPath, jsonPath.

    .EXAMPLE
        $plan = Get-AvmUpdatePlan -Path ./infra
        New-AvmUpdateReport -Plan $plan -OutputDirectory ./avm-report

    .EXAMPLE
        # Report scoped to only the LOW-risk items, for a split PR body
        New-AvmUpdateReport -Plan $plan -OutputDirectory ./avm-report/low -Items $lowItems
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Plan,

        [Parameter()]
        [string]$OutputDirectory = './avm-report',

        [Parameter()]
        [object[]]$Items
    )

    # Ensure output directory exists
    if (-not (Test-Path $OutputDirectory)) {
        if ($PSCmdlet.ShouldProcess($OutputDirectory, 'Create output directory')) {
            New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
        }
    }

    $reportPath = Join-Path $OutputDirectory 'avm-update-report.md'
    $jsonPath   = Join-Path $OutputDirectory 'avm-update-plan.json'

    $s  = $Plan.summary
    $isFiltered = $PSBoundParameters.ContainsKey('Items')
    # NOTE: the whole if/else must be wrapped in @(...) — assigning the bare if/else
    # expression collapses an empty-array branch result to $null instead of @().
    $candidates = @(if ($isFiltered) { $Items } else { $Plan.candidates })
    $lookupFailedItems       = @(if ($isFiltered) { } else { $Plan.lookupFailed })
    $ignoredItems            = @(if ($isFiltered) { } else { $Plan.ignored })
    $pinnedItems             = @(if ($isFiltered) { } else { $Plan.pinned })
    $upToDateByConstraintItems = @(if ($isFiltered) { } else { $Plan.upToDateByConstraint })
    $skippedConstraintItems    = @(if ($isFiltered) { } else { $Plan.skippedConstraints })
    $all = @($candidates) + @($lookupFailedItems)

    $high    = @($all | Where-Object riskTier -eq 'HIGH')
    $medium  = @($all | Where-Object riskTier -eq 'MEDIUM')
    $low     = @($all | Where-Object riskTier -eq 'LOW')
    $unknown = @($all | Where-Object riskTier -eq 'UNKNOWN')
    $failed  = @($lookupFailedItems)

    # Item counts always reflect $candidates (whole plan by default, or the -Items subset)
    # so split-PR reports show accurate scoped numbers instead of whole-plan totals.
    $totalUpdatesCount = $candidates.Count
    $majorCount = @($candidates | Where-Object updateType -eq 'major').Count
    $minorCount = @($candidates | Where-Object updateType -eq 'minor').Count
    $patchCount = @($candidates | Where-Object updateType -eq 'patch').Count

    $md = [System.Text.StringBuilder]::new()

    # --- Header ---
    $null = $md.AppendLine("# AVM Update Report")
    $null = $md.AppendLine()
    $null = $md.AppendLine("**Scan date:** $($s.scannedAt) ")
    $null = $md.AppendLine("**Paths scanned:** $($s.pathsScanned -join ', ') ")
    $null = $md.AppendLine()
    $null = $md.AppendLine("## Summary")
    $null = $md.AppendLine()
    $null = $md.AppendLine("| Metric | Count |")
    $null = $md.AppendLine("|--------|-------|")
    $null = $md.AppendLine("| Modules scanned | $($s.totalRefs) |")
    $null = $md.AppendLine("| Updates available | $totalUpdatesCount |")
    $null = $md.AppendLine("| Major bumps | $majorCount |")
    $null = $md.AppendLine("| Minor bumps | $minorCount |")
    $null = $md.AppendLine("| Patch bumps | $patchCount |")
    $null = $md.AppendLine("| HIGH risk | $($high.Count) |")
    $null = $md.AppendLine("| MEDIUM risk | $($medium.Count) |")
    $null = $md.AppendLine("| LOW risk | $($low.Count) |")
    $null = $md.AppendLine("| UNKNOWN risk | $($unknown.Count) |")
    $null = $md.AppendLine("| Lookup failed | $($failed.Count) |")
    $null = $md.AppendLine("| Ignored (config) | $($ignoredItems.Count) |")
    $null = $md.AppendLine("| Pinned (config) | $($pinnedItems.Count) |")
    $null = $md.AppendLine("| Up to date by constraint | $($upToDateByConstraintItems.Count) |")
    $null = $md.AppendLine("| Constraints skipped | $($skippedConstraintItems.Count) |")
    $null = $md.AppendLine()

    # Compute 'Module' / 'Registry' + 'CHANGELOG' / 'Releases' deep links for a candidate,
    # rendered as a single Markdown cell. Falls back to an em dash when links can't be built
    # (e.g. missing reference metadata) rather than failing report generation.
    function Get-CandidateLinksMarkdown([PSCustomObject]$Item) {
        $targetVersion = if ($Item.PSObject.Properties['resolvedVersion'] -and $Item.resolvedVersion) { $Item.resolvedVersion } else { $Item.latestStable }
        $reference = if ($Item.PSObject.Properties['reference']) { $Item.reference } else { $null }
        $links = if ($reference -and $targetVersion) { Get-AvmModuleLink -Reference $reference -TargetVersion $targetVersion } else { $null }
        if (-not $links) { return '—' }
        return "[$($links.primaryLabel)]($($links.primaryUrl)) · [$($links.secondaryLabel)]($($links.secondaryUrl))"
    }

    # --- Summary table ---
    if ($candidates.Count -gt 0) {
        $null = $md.AppendLine("## All Update Candidates")
        $null = $md.AppendLine()
        $null = $md.AppendLine("| Module | Ecosystem | Current | Latest | Jump | Risk | File:Line | Links |")
        $null = $md.AppendLine("|--------|-----------|---------|--------|------|------|-----------|-------|")

        # Sort: HIGH first, then MEDIUM, LOW, UNKNOWN
        $sorted = $candidates | Sort-Object {
            switch ($_.riskTier) { 'HIGH' { 0 } 'MEDIUM' { 1 } 'LOW' { 2 } default { 3 } }
        }, module

        foreach ($c in $sorted) {
            $fileRef = "$([System.IO.Path]::GetFileName($c.file)):$($c.lineNumber)"
            $linksMd = Get-CandidateLinksMarkdown -Item $c
            $null = $md.AppendLine("| $($c.module) | $($c.ecosystem) | $($c.currentVersion) | $($c.latestStable) | $($c.updateType) | $($c.riskTier) | $fileRef | $linksMd |")
        }
        $null = $md.AppendLine()
    }

    # --- Needs Review section (HIGH + MEDIUM) ---
    $needsReview = @($high) + @($medium)
    if ($needsReview.Count -gt 0) {
        $null = $md.AppendLine("## Needs Review (HIGH / MEDIUM)")
        $null = $md.AppendLine()
        foreach ($c in $needsReview) {
            $null = $md.AppendLine("### $($c.module) ($($c.ecosystem)) — $($c.riskTier)")
            $null = $md.AppendLine()
            $null = $md.AppendLine("- **File:** ``$($c.file):$($c.lineNumber)``")
            $null = $md.AppendLine("- **Change:** $($c.currentVersion) → $($c.latestStable) ($($c.updateType))")
            $null = $md.AppendLine("- **Links:** $(Get-CandidateLinksMarkdown -Item $c)")
            $null = $md.AppendLine()

            if ($c.riskReasons.Count -gt 0) {
                $null = $md.AppendLine("**Risk Reasons:**")
                $null = $md.AppendLine()
                foreach ($r in $c.riskReasons) { $null = $md.AppendLine("- $r") }
                $null = $md.AppendLine()
            }

            $diff = $c.interfaceDiff
            if ($diff -and $diff.available) {
                $null = $md.AppendLine("**Interface Diff:**")
                $null = $md.AppendLine()
                if ($diff.addedRequired.Count)  { $null = $md.AppendLine("- Added required inputs: $($diff.addedRequired -join ', ')") }
                if ($diff.removedInputs.Count)  { $null = $md.AppendLine("- Removed inputs: $($diff.removedInputs -join ', ')") }
                if ($diff.typeChanged.Count)    { $null = $md.AppendLine("- Type changes: $($diff.typeChanged -join ', ')") }
                if ($diff.addedOptional.Count)  { $null = $md.AppendLine("- Added optional inputs: $($diff.addedOptional -join ', ')") }
                if ($diff.removedOutputs.Count) { $null = $md.AppendLine("- Removed outputs: $($diff.removedOutputs -join ', ')") }
                if ($diff.addedOutputs.Count)   { $null = $md.AppendLine("- Added outputs: $($diff.addedOutputs -join ', ')") }
                $null = $md.AppendLine()
            }

            if ($c.changelogLines.Count -gt 0) {
                $null = $md.AppendLine("**Changelog Evidence:**")
                $null = $md.AppendLine()
                foreach ($l in $c.changelogLines) { $null = $md.AppendLine("- $l") }
                $null = $md.AppendLine()
            }
        }
    }

    # --- Likely Safe section (LOW) ---
    if ($low.Count -gt 0) {
        $null = $md.AppendLine("## Likely Safe (LOW)")
        $null = $md.AppendLine()
        $null = $md.AppendLine("| Module | Ecosystem | Current | Latest | File:Line | Links |")
        $null = $md.AppendLine("|--------|-----------|---------|--------|-----------|-------|")
        foreach ($c in $low) {
            $fileRef = "$([System.IO.Path]::GetFileName($c.file)):$($c.lineNumber)"
            $linksMd = Get-CandidateLinksMarkdown -Item $c
            $null = $md.AppendLine("| $($c.module) | $($c.ecosystem) | $($c.currentVersion) | $($c.latestStable) | $fileRef | $linksMd |")
        }
        $null = $md.AppendLine()
    }

    # --- Could Not Check (UNKNOWN + lookup-failed) ---
    $cantCheck = @($unknown) + @($failed)
    if ($cantCheck.Count -gt 0) {
        $null = $md.AppendLine("## Could Not Check")
        $null = $md.AppendLine()
        foreach ($c in $cantCheck) {
            $modName = try {
                if ($c.PSObject.Properties['module'] -and $null -ne $c.module) { $c.module }
                elseif ($c.PSObject.Properties['reference'] -and $null -ne $c.reference) { $c.reference.module }
                else { 'unknown' }
            } catch { 'unknown' }
            $null = $md.AppendLine("- **$modName**: $($c.riskReasons -join '; ')")
        }
        $null = $md.AppendLine()
    }

    # --- Up to date by constraint (Terraform "~>"/range constraints already satisfied) ---
    if ($upToDateByConstraintItems.Count -gt 0) {
        $null = $md.AppendLine("## Up To Date (Constraint Already Satisfied)")
        $null = $md.AppendLine()
        $null = $md.AppendLine('These Terraform module blocks use a version constraint that already permits the latest stable release — no file change is proposed and no PR is opened for them. Running `terraform init -upgrade` will resolve to the latest version within the constraint.')
        $null = $md.AppendLine()
        $null = $md.AppendLine("| Module | Constraint | Latest Stable | File:Line |")
        $null = $md.AppendLine("|--------|------------|----------------|-----------|")
        foreach ($u in $upToDateByConstraintItems) {
            $fileRef = "$([System.IO.Path]::GetFileName($u.file)):$($u.lineNumber)"
            $null = $md.AppendLine("| $($u.module) | $($u.currentVersion) | $($u.latestStable) | $fileRef |")
        }
        $null = $md.AppendLine()
    }

    # --- Skipped constraints (unparseable or complex/range constraints not auto-rewritten) ---
    if ($skippedConstraintItems.Count -gt 0) {
        $null = $md.AppendLine("## Skipped Constraints")
        $null = $md.AppendLine()
        $null = $md.AppendLine('These version constraints do not permit the latest stable release, but were left untouched rather than risk mangling a hand-written pin. Review and update manually.')
        $null = $md.AppendLine()
        $null = $md.AppendLine("| Module | Constraint | Latest Stable | Reason | File:Line |")
        $null = $md.AppendLine("|--------|------------|----------------|--------|-----------|")
        foreach ($sk in $skippedConstraintItems) {
            $fileRef = "$([System.IO.Path]::GetFileName($sk.file)):$($sk.lineNumber)"
            $reason = ($sk.riskReasons -join '; ')
            $null = $md.AppendLine("| $($sk.module) | $($sk.currentVersion) | $($sk.latestStable) | $reason | $fileRef |")
        }
        $null = $md.AppendLine()
    }

    # --- Ignored / Pinned (config policy) ---
    if ($ignoredItems.Count -gt 0 -or $pinnedItems.Count -gt 0) {
        $null = $md.AppendLine("## Ignored / Pinned")
        $null = $md.AppendLine()
        $null = $md.AppendLine('Modules excluded or capped by `ignoreModules` / `pinnedVersions` in `avmupdater.config.json`.')
        $null = $md.AppendLine()

        if ($ignoredItems.Count -gt 0) {
            $null = $md.AppendLine("### Ignored")
            $null = $md.AppendLine()
            $null = $md.AppendLine("| Module | Ecosystem | Current | Matched Pattern | File:Line |")
            $null = $md.AppendLine("|--------|-----------|---------|------------------|-----------|")
            foreach ($i in $ignoredItems) {
                $fileRef = "$([System.IO.Path]::GetFileName($i.file)):$($i.lineNumber)"
                $null = $md.AppendLine("| $($i.module) | $($i.ecosystem) | $($i.currentVersion) | $($i.matchedPattern) | $fileRef |")
            }
            $null = $md.AppendLine()
        }

        if ($pinnedItems.Count -gt 0) {
            $null = $md.AppendLine("### Pinned")
            $null = $md.AppendLine()
            $null = $md.AppendLine("| Module | Ecosystem | Current | Pin | Capped Target | Matched Pattern | File:Line |")
            $null = $md.AppendLine("|--------|-----------|---------|-----|----------------|------------------|-----------|")
            foreach ($p in $pinnedItems) {
                $fileRef = "$([System.IO.Path]::GetFileName($p.file)):$($p.lineNumber)"
                $cappedDisplay = if ($p.cappedVersion) { $p.cappedVersion } else { '*(no version available at/below pin)*' }
                $null = $md.AppendLine("| $($p.module) | $($p.ecosystem) | $($p.currentVersion) | $($p.pin) | $cappedDisplay | $($p.matchedPattern) | $fileRef |")
            }
            $null = $md.AppendLine()
        }
    }

    # Write files
    if ($PSCmdlet.ShouldProcess($reportPath, 'Write AVM update report')) {
        Set-Content -Path $reportPath -Value $md.ToString() -Encoding UTF8
    }
    if ($PSCmdlet.ShouldProcess($jsonPath, 'Write AVM update plan JSON')) {
        $Plan | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding UTF8
    }

    return [PSCustomObject]@{
        reportPath = $reportPath
        jsonPath   = $jsonPath
    }
}