internal/Audit/Compare-GuerrillaRun.ps1

# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution

function Compare-GuerrillaRun {
    <#
    .SYNOPSIS
        Pure diff of two run records: (previous, current) -> drift result.
    .DESCRIPTION
        No I/O, no clock, no state: everything the result contains is derived
        from the two inputs, which is what makes this function fixturable.

        Transition classes over the four verdicts (PASS, WARN, FAIL,
        Not Assessed), keyed by checkId|orgUnitPath:

          newlyFailing PASS/WARN -> FAIL regression, lead item
          lostVisibility assessed -> Not Assessed a check went dark; how
                                                        revoked read access or a
                                                        broken collector hides an
                                                        attacker. Never "no change".
          newlyPassing FAIL/WARN -> PASS remediation confirmed
          regressed PASS -> WARN
          improved FAIL -> WARN
          restoredVisibility Not Assessed -> assessed (current verdict shown;
                                                        an NA->FAIL restoration is
                                                        visible failure, listed here)
          unchanged same verdict count only, never enumerated

        Checks present only in the current run are NEW (module upgrade), never a
        transition. Checks present only in the previous run are RETIRED, never a
        transition. An unrecognized verdict pair THROWS: a silent diff is a
        false PASS, so unclassifiable input must be loud.

        The result carries TotalClassified and InputUnionCount; they are equal
        by construction and asserted here, and the golden fixtures assert the
        same equality from the outside.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Previous,
        [Parameter(Mandatory)]$Current
    )

    $validVerdicts = @('PASS', 'WARN', 'FAIL', 'Not Assessed')

    # --- First run ever: no baseline, say so plainly, fabricate nothing. ---
    if ($null -eq $Previous) {
        return [PSCustomObject]@{
            PSTypeName        = 'Guerrilla.RunDiff'
            BaselineRun       = $true
            Previous          = $null
            Current           = [PSCustomObject]@{
                RunId         = "$($Current.runId)"
                GeneratedAt   = "$($Current.generatedAt)"
                ModuleVersion = "$($Current.moduleVersion)"
                OverallScore  = $Current.overallScore
            }
            VersionSkew       = $false
            NewlyFailing      = @()
            LostVisibility    = @()
            NewlyPassing      = @()
            Regressed         = @()
            Improved          = @()
            RestoredVisibility = @()
            NewChecks         = @()
            RetiredChecks     = @()
            UnchangedCount    = 0
            ScoreDelta        = $null
            PillarDeltas      = @()
            NotAssessedDelta  = $null
            TotalClassified   = 0
            InputUnionCount   = 0
        }
    }

    # --- Index both sides by identity key. ---
    $keyOf = { param($c) "$($c.checkId)|$($c.orgUnitPath)" }
    $prevByKey = @{}
    foreach ($c in @($Previous.checks)) { $prevByKey[(& $keyOf $c)] = $c }
    $currByKey = @{}
    foreach ($c in @($Current.checks)) { $currByKey[(& $keyOf $c)] = $c }

    $entryOf = {
        param($c, $from, $to)
        [PSCustomObject]@{
            CheckId         = "$($c.checkId)"
            OrgUnitPath     = "$($c.orgUnitPath)"
            Severity        = "$($c.severity)"
            Category        = "$($c.category)"
            ZeroTrustPillar = "$($c.zeroTrustPillar)"
            From            = $from
            To              = $to
            EvidenceChanged = $null   # set below where both sides carry a hash
        }
    }

    $newlyFailing = [System.Collections.Generic.List[object]]::new()
    $lostVisibility = [System.Collections.Generic.List[object]]::new()
    $newlyPassing = [System.Collections.Generic.List[object]]::new()
    $regressed = [System.Collections.Generic.List[object]]::new()
    $improved = [System.Collections.Generic.List[object]]::new()
    $restoredVisibility = [System.Collections.Generic.List[object]]::new()
    $newChecks = [System.Collections.Generic.List[object]]::new()
    $retiredChecks = [System.Collections.Generic.List[object]]::new()
    $unchangedCount = 0

    foreach ($key in $currByKey.Keys) {
        $curr = $currByKey[$key]
        $to = "$($curr.verdict)"
        if ($to -notin $validVerdicts) { throw "Compare-GuerrillaRun: unknown current verdict '$to' for $key." }

        if (-not $prevByKey.ContainsKey($key)) {
            # New in this run (module upgrade or widened collection): a label, not a transition.
            $newChecks.Add((& $entryOf $curr $null $to))
            continue
        }

        $prev = $prevByKey[$key]
        $from = "$($prev.verdict)"
        if ($from -notin $validVerdicts) { throw "Compare-GuerrillaRun: unknown previous verdict '$from' for $key." }

        $entry = & $entryOf $curr $from $to
        if ($prev.evidenceHash -and $curr.evidenceHash) {
            $entry.EvidenceChanged = ("$($prev.evidenceHash)" -ne "$($curr.evidenceHash)")
        }

        # Exhaustive transition matrix. The default arm throws on purpose:
        # every pair must land in exactly one class or the diff is lying.
        switch ("$from>$to") {
            'PASS>PASS'                 { $unchangedCount++ }
            'WARN>WARN'                 { $unchangedCount++ }
            'FAIL>FAIL'                 { $unchangedCount++ }
            'Not Assessed>Not Assessed' { $unchangedCount++ }
            'PASS>FAIL'                 { $newlyFailing.Add($entry) }
            'WARN>FAIL'                 { $newlyFailing.Add($entry) }
            'PASS>Not Assessed'         { $lostVisibility.Add($entry) }
            'WARN>Not Assessed'         { $lostVisibility.Add($entry) }
            'FAIL>Not Assessed'         { $lostVisibility.Add($entry) }
            'FAIL>PASS'                 { $newlyPassing.Add($entry) }
            'WARN>PASS'                 { $newlyPassing.Add($entry) }
            'PASS>WARN'                 { $regressed.Add($entry) }
            'FAIL>WARN'                 { $improved.Add($entry) }
            'Not Assessed>PASS'         { $restoredVisibility.Add($entry) }
            'Not Assessed>WARN'         { $restoredVisibility.Add($entry) }
            'Not Assessed>FAIL'         { $restoredVisibility.Add($entry) }
            default { throw "Compare-GuerrillaRun: unclassified transition '$from' -> '$to' for $key. A silent diff is a false PASS." }
        }
    }

    foreach ($key in $prevByKey.Keys) {
        if (-not $currByKey.ContainsKey($key)) {
            $prev = $prevByKey[$key]
            $from = "$($prev.verdict)"
            if ($from -notin $validVerdicts) { throw "Compare-GuerrillaRun: unknown previous verdict '$from' for $key." }
            # Present before, absent now (removed in an upgrade): a label, not a transition.
            $retiredChecks.Add((& $entryOf $prev $from $null))
        }
    }

    # --- Count equality: every check in the input union appears exactly once. ---
    $unionKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    foreach ($k in $prevByKey.Keys) { [void]$unionKeys.Add($k) }
    foreach ($k in $currByKey.Keys) { [void]$unionKeys.Add($k) }
    $totalClassified = $newlyFailing.Count + $lostVisibility.Count + $newlyPassing.Count +
        $regressed.Count + $improved.Count + $restoredVisibility.Count +
        $newChecks.Count + $retiredChecks.Count + $unchangedCount
    if ($totalClassified -ne $unionKeys.Count) {
        throw ("Compare-GuerrillaRun: classified $totalClassified of $($unionKeys.Count) checks. " +
            'A transition fell through the matrix; refusing to return a partial diff.')
    }

    # --- Score deltas. Null-safe: a side without a score yields a null delta, not zero. ---
    $scoreDelta = if ($null -ne $Previous.overallScore -and $null -ne $Current.overallScore) {
        [int]$Current.overallScore - [int]$Previous.overallScore
    } else { $null }

    # A record's pillarScores is an IDictionary when freshly built and a
    # PSCustomObject when read back from JSON; normalize both to a hashtable.
    $toMap = {
        param($scores)
        $map = @{}
        if ($null -eq $scores) { return $map }
        if ($scores -is [System.Collections.IDictionary]) {
            foreach ($k in $scores.Keys) { $map["$k"] = $scores[$k] }
        } else {
            foreach ($prop in $scores.PSObject.Properties) { $map["$($prop.Name)"] = $prop.Value }
        }
        return $map
    }
    $prevPillars = & $toMap $Previous.pillarScores
    $currPillars = & $toMap $Current.pillarScores
    $pillarNames = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($p in $prevPillars.Keys) { [void]$pillarNames.Add($p) }
    foreach ($p in $currPillars.Keys) { [void]$pillarNames.Add($p) }
    $pillarDeltas = foreach ($name in $pillarNames) {
        $pv = $prevPillars[$name]
        $cv = $currPillars[$name]
        [PSCustomObject]@{
            Pillar   = $name
            Previous = $pv
            Current  = $cv
            Delta    = if ($null -ne $pv -and $null -ne $cv) { [int]$cv - [int]$pv } else { $null }
        }
    }

    $prevNA = $Previous.summary.notAssessed
    $currNA = $Current.summary.notAssessed
    $notAssessedDelta = if ($null -ne $prevNA -and $null -ne $currNA) { [int]$currNA - [int]$prevNA } else { $null }

    [PSCustomObject]@{
        PSTypeName        = 'Guerrilla.RunDiff'
        BaselineRun       = $false
        Previous          = [PSCustomObject]@{
            RunId         = "$($Previous.runId)"
            GeneratedAt   = "$($Previous.generatedAt)"
            ModuleVersion = "$($Previous.moduleVersion)"
            OverallScore  = $Previous.overallScore
        }
        Current           = [PSCustomObject]@{
            RunId         = "$($Current.runId)"
            GeneratedAt   = "$($Current.generatedAt)"
            ModuleVersion = "$($Current.moduleVersion)"
            OverallScore  = $Current.overallScore
        }
        VersionSkew       = ("$($Previous.moduleVersion)" -ne "$($Current.moduleVersion)")
        NewlyFailing      = @($newlyFailing)
        LostVisibility    = @($lostVisibility)
        NewlyPassing      = @($newlyPassing)
        Regressed         = @($regressed)
        Improved          = @($improved)
        RestoredVisibility = @($restoredVisibility)
        NewChecks         = @($newChecks)
        RetiredChecks     = @($retiredChecks)
        UnchangedCount    = $unchangedCount
        ScoreDelta        = $scoreDelta
        PillarDeltas      = @($pillarDeltas)
        NotAssessedDelta  = $notAssessedDelta
        TotalClassified   = $totalClassified
        InputUnionCount   = $unionKeys.Count
    }
}