modules/reports/New-DriftReport.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Generate machine-readable and markdown drift reports from entity snapshot diffs.
#>

[CmdletBinding()]
param (
    [hashtable] $Comparison,
    [string] $PreviousSnapshot,
    [string] $CurrentSnapshot,
    [string] $OutputPath = (Join-Path $PSScriptRoot '..' '..' 'output')
)

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

$sharedRoot = Join-Path $PSScriptRoot '..' 'shared'
$sanitizePath = Join-Path $sharedRoot 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$comparePath = Join-Path $sharedRoot 'Compare-EntitySnapshots.ps1'
if (Test-Path $comparePath) { . $comparePath }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param ([string] $Text) return $Text }
}

if (-not $Comparison) {
    if (-not $PreviousSnapshot -or -not $CurrentSnapshot) {
        throw "Provide -Comparison or both -PreviousSnapshot and -CurrentSnapshot."
    }
    $Comparison = Compare-EntitySnapshots -Previous $PreviousSnapshot -Current $CurrentSnapshot
}

if (-not (Test-Path $OutputPath)) {
    $null = New-Item -ItemType Directory -Path $OutputPath -Force
}

$added = @($Comparison.Added)
$removed = @($Comparison.Removed)
$modified = @($Comparison.Modified)
$unchanged = @($Comparison.Unchanged)

$all = @($added + $removed + $modified + $unchanged)
$entityTypes = @($all | ForEach-Object { if ($_.EntityType) { [string]$_.EntityType } else { 'Unknown' } } | Sort-Object -Unique)

$byType = [ordered]@{}
foreach ($entityType in $entityTypes) {
    $byType[$entityType] = [ordered]@{
        Added     = @($added | Where-Object { (($_.EntityType ?? 'Unknown')) -eq $entityType }).Count
        Removed   = @($removed | Where-Object { (($_.EntityType ?? 'Unknown')) -eq $entityType }).Count
        Modified  = @($modified | Where-Object { (($_.EntityType ?? 'Unknown')) -eq $entityType }).Count
        Unchanged = @($unchanged | Where-Object { (($_.EntityType ?? 'Unknown')) -eq $entityType }).Count
    }
}

$report = [ordered]@{
    SchemaVersion = '1.0'
    GeneratedAt   = (Get-Date).ToUniversalTime().ToString('o')
    PreviousSnapshot = $PreviousSnapshot
    CurrentSnapshot  = $CurrentSnapshot
    Summary = [ordered]@{
        Added     = $added.Count
        Removed   = $removed.Count
        Modified  = $modified.Count
        Unchanged = $unchanged.Count
        TotalCompared = $all.Count
    }
    ByEntityType = $byType
    Changes = [ordered]@{
        Added     = $added
        Removed   = $removed
        Modified  = $modified
        Unchanged = $unchanged
    }
}

$jsonPath = Join-Path $OutputPath 'drift-report.json'
$mdPath = Join-Path $OutputPath 'drift-report.md'

try {
    $json = $report | ConvertTo-Json -Depth 100
    Set-Content -Path $jsonPath -Value (Remove-Credentials $json) -Encoding UTF8
} catch {
    throw (Remove-Credentials "Failed to write drift-report.json: $_")
}

$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add('# Entity drift report')
$lines.Add('')
$lines.Add("| Metric | Count |")
$lines.Add("|---|---:|")
$lines.Add("| Added | $($added.Count) |")
$lines.Add("| Removed | $($removed.Count) |")
$lines.Add("| Modified | $($modified.Count) |")
$lines.Add("| Unchanged | $($unchanged.Count) |")
$lines.Add('')

if ($PreviousSnapshot) { $lines.Add(('- Previous: `{0}`' -f $PreviousSnapshot)) }
if ($CurrentSnapshot) { $lines.Add(('- Current: `{0}`' -f $CurrentSnapshot)) }
$lines.Add('')

function Add-ChangeTable {
    param (
        [Parameter(Mandatory)][string] $Title,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Rows
    )

    $lines.Add("## $Title")
    $lines.Add('')
    if (@($Rows).Count -eq 0) {
        $lines.Add('No entries.')
        $lines.Add('')
        return
    }

    $groups = @($Rows | Group-Object -Property { if ($_.EntityType) { $_.EntityType } else { 'Unknown' } } | Sort-Object Name)
    foreach ($group in $groups) {
        $lines.Add("### $($group.Name)")
        $lines.Add('')
        $lines.Add('| EntityId | Severity | Changed fields |')
        $lines.Add('|---|---|---|')
        foreach ($row in @($group.Group | Sort-Object EntityId)) {
            $changed = if ($row.ChangedPaths -and @($row.ChangedPaths).Count -gt 0) {
                (@($row.ChangedPaths) -join ', ') -replace '\|', '\|'
            } else {
                '-'
            }
            $entityId = ([string]$row.EntityId) -replace '\|', '\|'
            $severity = if ($row.Severity) { [string]$row.Severity } else { 'Info' }
            $lines.Add("| $entityId | $severity | $changed |")
        }
        $lines.Add('')
    }
}

Add-ChangeTable -Title 'Added entities' -Rows $added
Add-ChangeTable -Title 'Removed entities' -Rows $removed
Add-ChangeTable -Title 'Modified entities' -Rows $modified

try {
    Set-Content -Path $mdPath -Value (Remove-Credentials ($lines -join "`n")) -Encoding UTF8 -NoNewline
} catch {
    throw (Remove-Credentials "Failed to write drift-report.md: $_")
}