Private/RunDiff.ps1

# Run-to-run diff — compares the current results against a previous run's JSON
# report so the report answers *what* changed, not just that the score moved.
# Keying: control + subscription name + resource (same identity the JSON export
# and SARIF fingerprints use).

function Find-PreviousReportJson {
    <#
    .SYNOPSIS
    Locate the most recent report JSON in the output directory ('auto' baseline).
    Excludes the current run's own JSON, run history, diff outputs and suppressions.
    #>

    param([Parameter(Mandatory)][string]$OutputPath)

    $fullOut     = [System.IO.Path]::GetFullPath($OutputPath)
    $dir         = Split-Path $fullOut -Parent
    $currentJson = [System.IO.Path]::ChangeExtension($fullOut, '.json')

    $candidates = @(Get-ChildItem -Path $dir -Filter '*.json' -File -ErrorAction SilentlyContinue |
        Where-Object {
            $_.FullName -ne $currentJson -and
            $_.Name -ne 'cis_run_history.json' -and
            $_.Name -notlike '*.diff.json' -and
            $_.Name -notlike 'suppressions.json*'
        } |
        Sort-Object LastWriteTimeUtc -Descending)

    if ($candidates.Count -gt 0) { return $candidates[0].FullName }
    return $null
}

function Get-DiffEntryValue {
    # Tolerant property read for parsed-JSON baseline entries (missing key -> '').
    param([Parameter(Mandatory)][object]$Entry, [Parameter(Mandatory)][string]$Name)
    $p = $Entry.PSObject.Properties[$Name]
    if ($p -and $null -ne $p.Value) { [string]$p.Value } else { '' }
}

function Get-RunDiff {
    <#
    .SYNOPSIS
    Classify changes between the current results and a previous run's exported
    JSON results (array of { control, title, level, subscription, resource,
    status, details }).

    .DESCRIPTION
    Categories (FAIL and ERROR both count as failing, matching the score):
      Regressions — present in both runs; was not failing, now FAIL/ERROR
      Improvements — present in both runs; was FAIL/ERROR, now not failing
      New — result key only in the current run
      Removed — result key only in the previous run
    Status changes between neutral states (e.g. MANUAL to INFO) and FAIL/ERROR
    swaps are not reported — the finding neither appeared nor went away.
    #>

    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$CurrentResults,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$PreviousResults
    )

    $bad = @($script:FAIL, $script:ERR)

    function New-DiffEntry {
        param([string]$Control, [string]$Title, [int]$Level, [string]$Subscription,
              [string]$Resource, [string]$Status, [string]$Details, [string]$PreviousStatus)
        [PSCustomObject][ordered]@{
            control        = $Control
            title          = $Title
            level          = $Level
            subscription   = $Subscription
            resource       = $Resource
            status         = $Status
            previousStatus = $PreviousStatus
            details        = $Details
        }
    }

    $curMap = [ordered]@{}
    foreach ($r in $CurrentResults) {
        $key = '{0}|{1}|{2}' -f $r.ControlId, $r.SubscriptionName, $r.Resource
        if (-not $curMap.Contains($key)) { $curMap[$key] = $r }
    }

    $prevMap = [ordered]@{}
    foreach ($p in $PreviousResults) {
        $key = '{0}|{1}|{2}' -f (Get-DiffEntryValue -Entry $p -Name 'control'), (Get-DiffEntryValue -Entry $p -Name 'subscription'), (Get-DiffEntryValue -Entry $p -Name 'resource')
        if (-not $prevMap.Contains($key)) { $prevMap[$key] = $p }
    }

    $regressions  = [System.Collections.Generic.List[object]]::new()
    $improvements = [System.Collections.Generic.List[object]]::new()
    $new          = [System.Collections.Generic.List[object]]::new()
    $removed      = [System.Collections.Generic.List[object]]::new()

    foreach ($key in $curMap.Keys) {
        $r = $curMap[$key]
        $prevStatus = if ($prevMap.Contains($key)) { Get-DiffEntryValue -Entry $prevMap[$key] -Name 'status' } else { $null }
        $entry = New-DiffEntry -Control $r.ControlId -Title $r.Title -Level ([int]$r.Level) `
            -Subscription ([string]$r.SubscriptionName) -Resource ([string]$r.Resource) `
            -Status $r.Status -Details ([string]$r.Details) -PreviousStatus ([string]$prevStatus)

        if ($null -eq $prevStatus) {
            $new.Add($entry)
        } elseif ($prevStatus -notin $bad -and $r.Status -in $bad) {
            $regressions.Add($entry)
        } elseif ($prevStatus -in $bad -and $r.Status -notin $bad) {
            $improvements.Add($entry)
        }
    }

    foreach ($key in $prevMap.Keys) {
        if ($curMap.Contains($key)) { continue }
        $p = $prevMap[$key]
        $lvl = 0; $null = [int]::TryParse((Get-DiffEntryValue -Entry $p -Name 'level'), [ref]$lvl)
        $removed.Add((New-DiffEntry -Control (Get-DiffEntryValue -Entry $p -Name 'control') `
            -Title (Get-DiffEntryValue -Entry $p -Name 'title') -Level $lvl `
            -Subscription (Get-DiffEntryValue -Entry $p -Name 'subscription') -Resource (Get-DiffEntryValue -Entry $p -Name 'resource') `
            -Status '' -Details (Get-DiffEntryValue -Entry $p -Name 'details') `
            -PreviousStatus (Get-DiffEntryValue -Entry $p -Name 'status')))
    }

    [PSCustomObject]@{
        Regressions  = @($regressions)
        Improvements = @($improvements)
        New          = @($new)
        Removed      = @($removed)
    }
}

function Write-RunDiffJson {
    <#
    .SYNOPSIS
    Write the diff to <report>.diff.json (BOM-less UTF-8, like the JSON report).
    #>

    param(
        [Parameter(Mandatory)][object]$Diff,
        [Parameter(Mandatory)][string]$BaselinePath,
        [Parameter(Mandatory)][string]$OutputPath
    )

    $payload = [ordered]@{
        baseline     = [System.IO.Path]::GetFullPath($BaselinePath)
        generated    = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
        counts       = [ordered]@{
            regressions  = $Diff.Regressions.Count
            improvements = $Diff.Improvements.Count
            new          = $Diff.New.Count
            removed      = $Diff.Removed.Count
        }
        regressions  = @($Diff.Regressions)
        improvements = @($Diff.Improvements)
        new          = @($Diff.New)
        removed      = @($Diff.Removed)
    }
    $jsonText = ConvertTo-Json -InputObject $payload -Depth 5
    [System.IO.File]::WriteAllText($OutputPath, $jsonText, [System.Text.UTF8Encoding]::new($false))
}