Private/Compliance/Write-GovernanceReport.ps1

function Write-GovernanceReport {
    <#
        .SYNOPSIS
        Findings summary + report files, shared by the reconcile (audit /
        apply / WhatIf) and preflight. Prints the console summary, and when
        ReportPath is set writes the text report plus its machine-readable
        JSON twin (findings classified by prefix — consumed by scheduled runs
        and, eventually, the compliance dashboard).

        Findings are the prefix-classified strings the whole engine emits:
        MISSING / DRIFT (no prefix) / ERROR / UNRESOLVABLE / AUDIT EXCEPTION.
        InfoLines are context that is NOT a finding (preflight uses them for
        mapping guidance); they never affect the finding count or CI exit.

        FindingObjects is the structured alternative (ADR-008): each entry is
        a dictionary with at least `class` (missing | drift | error |
        unresolvable | exception) and `message` (the prefixed human line).
        The text report renders the messages exactly as it renders strings;
        the JSON twin writes the objects whole, so check ids, subjects,
        counts and engagement labels survive into machine-readable output.
        Pass one or the other, not both.

        Returns the mode's finding-count suffix ('found', 'remaining after
        apply', …) so the caller can word its terminating error identically.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyCollection()][string[]]$Findings = @(),
        [AllowEmptyCollection()][object[]]$FindingObjects = $null,
        [Parameter(Mandatory)][string]$ProgramName,
        [Parameter(Mandatory)][string]$Project,
        [Parameter(Mandatory)][string]$OrgUrl,
        [Parameter(Mandatory)][string]$Mode,
        [string]$ReportPath = '',
        [string]$Title = 'Governance audit report',
        [AllowEmptyCollection()][string[]]$ExtraHeader = @(),
        [AllowEmptyCollection()][string[]]$InfoLines = @()
    )

    $structured = $PSBoundParameters.ContainsKey('FindingObjects')
    if ($structured) {
        if ($PSBoundParameters.ContainsKey('Findings') -and $Findings.Count -gt 0) {
            throw 'Write-GovernanceReport: pass either -Findings (strings) or -FindingObjects, not both.'
        }
        foreach ($f in @($FindingObjects)) {
            if (-not $f.class -or -not $f.message) { throw "Write-GovernanceReport: every finding object needs 'class' and 'message' (got: $($f | ConvertTo-Json -Compress -Depth 3))." }
        }
        $Findings = @(@($FindingObjects) | ForEach-Object { [string]$_.message })
    }

    $exceptions = @($Findings | Where-Object { $_ -like 'AUDIT EXCEPTION*' })
    $missing    = @($Findings | Where-Object { $_ -like 'MISSING*' })
    $errors     = @($Findings | Where-Object { $_ -like 'ERROR*' -or $_ -like 'UNRESOLVABLE*' })
    $drift      = @($Findings | Where-Object {
        $_ -notlike 'AUDIT EXCEPTION*' -and $_ -notlike 'MISSING*' -and
        $_ -notlike 'ERROR*' -and $_ -notlike 'UNRESOLVABLE*' })

    # Group each error under its diagnosed root cause so the summary reads as
    # "here is what failed and WHY", not a wall of identical stack noise.
    $errorsByWhy = [ordered]@{}
    foreach ($e in $errors) {
        $why = Resolve-GovernanceErrorReason -Finding $e
        if (-not $why) { $why = 'cause not yet diagnosed - investigate, then teach Resolve-GovernanceErrorReason the signature' }
        if (-not $errorsByWhy.Contains($why)) { $errorsByWhy[$why] = [System.Collections.Generic.List[string]]::new() }
        $errorsByWhy[$why].Add($e)
    }

    $suffix = switch ($Mode) {
        'Apply'  { 'remaining after apply' }
        'WhatIf' { 'found (dry-run — no changes made)' }
        default  { 'found' }
    }

    Write-Host ''
    if ($Findings.Count -eq 0) {
        Write-Host "COMPLIANT — zero findings." -ForegroundColor Green
    } else {
        Write-Host "NON-COMPLIANT — $($Findings.Count) finding(s) $suffix." -ForegroundColor Red
        if ($missing.Count -gt 0) {
            Write-Host "`n Missing ($($missing.Count)):" -ForegroundColor Red
            $missing | ForEach-Object { Write-Host " * $_" -ForegroundColor Red }
        }
        if ($drift.Count -gt 0) {
            Write-Host "`n Drift ($($drift.Count)):" -ForegroundColor Red
            $drift | ForEach-Object { Write-Host " * $_" -ForegroundColor Red }
        }
        if ($errors.Count -gt 0) {
            Write-Host "`n Errors and why ($($errors.Count)):" -ForegroundColor Red
            foreach ($why in $errorsByWhy.Keys) {
                Write-Host " WHY: $why" -ForegroundColor Yellow
                foreach ($e in $errorsByWhy[$why]) { Write-Host " * $e" -ForegroundColor Red }
            }
        }
        if ($exceptions.Count -gt 0) {
            Write-Host "`n Audit failures — exist in ADO but not in config ($($exceptions.Count)):" -ForegroundColor Magenta
            $exceptions | ForEach-Object { Write-Host " * $_" -ForegroundColor Magenta }
        }
    }
    if ($InfoLines.Count -gt 0) {
        Write-Host "`n Info — not findings ($($InfoLines.Count)):" -ForegroundColor DarkCyan
        $InfoLines | ForEach-Object { Write-Host " * $_" -ForegroundColor DarkCyan }
    }

    if ($ReportPath) {
        $reportDir = Split-Path $ReportPath -Parent
        if ($reportDir -and -not (Test-Path $reportDir)) {
            New-Item -ItemType Directory -Path $reportDir -Force | Out-Null
        }
        $lines = [System.Collections.Generic.List[string]]::new()
        $lines.Add($Title)
        $lines.Add("Program : $Project")
        $lines.Add("Org : $OrgUrl")
        foreach ($h in $ExtraHeader) { $lines.Add($h) }
        $lines.Add("Mode : $Mode")
        $lines.Add("Generated: $(Get-Date -Format 'o')")
        $lines.Add("Findings : $($Findings.Count)")
        $lines.Add('')
        $errorReportItems = @(foreach ($why in $errorsByWhy.Keys) {
            "WHY: $why"
            foreach ($e in $errorsByWhy[$why]) { " - $e" }
        })
        foreach ($section in @(
            @{ label = 'MISSING'; items = $missing },
            @{ label = 'DRIFT';   items = $drift },
            @{ label = 'ERRORS AND WHY'; items = $errorReportItems },
            @{ label = 'AUDIT FAILURES (exist in ADO but not in config)'; items = $exceptions },
            @{ label = 'INFO (not findings)'; items = $InfoLines }
        )) {
            if ($section.items.Count -eq 0) { continue }
            $lines.Add("$($section.label) ($($section.items.Count)):")
            $section.items | ForEach-Object { $lines.Add(" - $_") }
            $lines.Add('')
        }
        if ($Findings.Count -eq 0) { $lines.Add('COMPLIANT') } else { $lines.Add('NON-COMPLIANT') }
        Set-Content -Path $ReportPath -Value $lines -Encoding utf8
        Write-Host "`nReport written to: $ReportPath" -ForegroundColor Cyan

        $jsonPath   = [System.IO.Path]::ChangeExtension($ReportPath, 'json')
        $classified = if ($structured) {
            @(@($FindingObjects) | ForEach-Object {
                # class first, then everything the analysis attached, then why.
                $entry = [ordered]@{ class = [string]$_.class }
                foreach ($k in @($_.Keys)) { if ($k -ne 'class') { $entry[$k] = $_[$k] } }
                if ($entry.class -in 'error', 'unresolvable' -and -not $entry.Contains('why')) {
                    $entry['why'] = Resolve-GovernanceErrorReason -Finding ([string]$_.message)
                }
                $entry
            })
        } else {
            @($Findings | ForEach-Object {
                $class = if ($_ -like 'MISSING*')              { 'missing' }
                         elseif ($_ -like 'AUDIT EXCEPTION*')  { 'exception' }
                         elseif ($_ -like 'UNRESOLVABLE*')     { 'unresolvable' }
                         elseif ($_ -like 'ERROR*')            { 'error' }
                         else                                  { 'drift' }
                $entry = [ordered]@{ class = $class; message = $_ }
                if ($class -in 'error', 'unresolvable') {
                    $entry['why'] = Resolve-GovernanceErrorReason -Finding $_
                }
                $entry
            })
        }
        $doc = [ordered]@{
            program      = $ProgramName
            project      = $Project
            org          = $OrgUrl
            mode         = $Mode
            generated    = (Get-Date).ToUniversalTime().ToString('o')
            compliant    = ($Findings.Count -eq 0)
            findingCount = $Findings.Count
            findings     = $classified
        }
        if ($InfoLines.Count -gt 0) { $doc['info'] = @($InfoLines) }
        $doc | ConvertTo-Json -Depth 5 | Set-Content -Path $jsonPath -Encoding utf8
        Write-Host "JSON report written to: $jsonPath" -ForegroundColor Cyan
    }

    return $suffix
}