src/report/Get-ScoutDrift.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Compute cross-run drift (New/Resolved/Regressed/Unchanged + score delta) for a scored Findings set against the immediately previous run, and append the current run to a durable findings-history log. .DESCRIPTION Maintains a single append-only history file (findings-history.json) under -HistoryPath: one compact record per run, keyed by -RunId, capturing every finding's Status plus a weighted OverallScore (same Area-weighted-average math Get-Score uses for a Framework score, just rolled up across every scored area regardless of framework). On each call: 1. Reads the history file (tolerant of a missing file or malformed/ corrupt JSON — both are treated as "no history", never throrow). 2. Picks the most recent prior record whose RunId differs from the current -RunId as the comparison baseline. 3. Classifies every current finding as one of: New - no record of this finding Id in the previous run Resolved - previous run had Fail/Partial, current run has Pass Regressed - previous run had Pass, current run has Fail/Partial Unchanged - anything else (same status, or a change that isn't the Resolved/Regressed pattern above, e.g. Partial -> Fail) 4. Appends/replaces the current run's record in the history file (a rerun with the same -RunId overwrites its own prior record rather than duplicating it). On the first-ever run for a given -HistoryPath (no usable prior record), returns an explicit baseline drift object — IsBaseline = $true, every finding classified 'New', PreviousScore/ScoreDelta = $null — rather than throwing or returning nothing, so callers (e.g. Export-React's Drift tab) can render a "baseline run" state without special-casing a null return. .PARAMETER Findings The scored Findings object from Get-Score (GeneratedOn/Frameworks/Areas/ Gaps/Manual/Errors/Findings). .PARAMETER HistoryPath Folder the findings-history.json log lives in. Created if missing. Defaults to a .scout-history folder under the current location if not supplied — callers should normally pass an explicit path (e.g. a .scout-history folder under the assessment's -OutputPath root) so every run in the same output tree shares one history log. .PARAMETER RunId The caller-supplied run identifier (e.g. Invoke-ScoutAssessment's yyyyMMdd_HHmmss run folder name) stamped onto this run's history record. Required — drift has no meaning without a stable, caller-controlled id to compare across. .OUTPUTS [pscustomobject] with RunId/GeneratedOn/IsBaseline/PreviousRunId/ OverallScore/PreviousScore/ScoreDelta/Summary/Findings — see tests/Report.Drift.Tests.ps1 for the exact shape exercised. .NOTES Tracks ADO Story AB#5053. #> function Get-ScoutDrift { param( $Findings, [string] $HistoryPath, [string] $RunId ) if ($null -eq $Findings) { throw 'Get-ScoutDrift: -Findings is required.' } if ([string]::IsNullOrWhiteSpace($RunId)) { throw 'Get-ScoutDrift: -RunId is required (pass the caller-controlled run id, e.g. the assessment run-folder name).' } if ([string]::IsNullOrWhiteSpace($HistoryPath)) { $HistoryPath = Join-Path (Get-Location) 'output' '.scout-history' } if (-not (Test-Path $HistoryPath)) { New-Item -ItemType Directory -Path $HistoryPath -Force | Out-Null } $historyFile = Join-Path $HistoryPath 'findings-history.json' function Get-ScoutDriftProp { param($Obj, [string] $Name) if ($null -eq $Obj) { return $null } $p = $Obj.PSObject.Properties[$Name] if ($p) { return $p.Value } else { return $null } } function Get-ScoutOverallScore { # Weighted mean of every scored area's Score by its AreaWeight — the # same math Get-Score uses per-framework, just rolled up across all # areas so drift has a single headline number to diff. param($ScoredFindings) $areas = @(Get-ScoutDriftProp $ScoredFindings 'Areas') $scorable = @($areas | Where-Object { $null -ne (Get-ScoutDriftProp $_ 'Score') }) if ($scorable.Count -eq 0) { return $null } $wsum = ($scorable | ForEach-Object { $w = Get-ScoutDriftProp $_ 'Weight' if ($null -eq $w) { 1.0 } else { [double]$w } } | Measure-Object -Sum).Sum $wnum = ($scorable | ForEach-Object { $w = Get-ScoutDriftProp $_ 'Weight' if ($null -eq $w) { $w = 1.0 } else { $w = [double]$w } (Get-ScoutDriftProp $_ 'Score') * $w } | Measure-Object -Sum).Sum if ($wsum -gt 0) { return [math]::Round($wnum / $wsum, 0, [System.MidpointRounding]::AwayFromZero) } return $null } # ---- read prior history (tolerant: missing/corrupt => baseline, never throw) ---- $history = @() if (Test-Path $historyFile) { try { $raw = Get-Content $historyFile -Raw -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($raw)) { $parsed = $raw | ConvertFrom-Json -Depth 100 -ErrorAction Stop if ($null -ne $parsed) { $history = @($parsed) } } } catch { # Malformed/corrupt history file — treat as "no history" (baseline). $history = @() } } $priorCandidates = @($history | Where-Object { (Get-ScoutDriftProp $_ 'RunId') -ne $RunId }) $previous = if ($priorCandidates.Count -gt 0) { $priorCandidates[-1] } else { $null } $isBaseline = ($null -eq $previous) # ---- current run's findings, keyed by Id ---- $currentFindings = @(Get-ScoutDriftProp $Findings 'Findings') $currentMap = @{} foreach ($f in $currentFindings) { $id = Get-ScoutDriftProp $f 'Id' if ([string]::IsNullOrEmpty($id)) { continue } $currentMap[$id] = $f } $previousStatuses = if ($isBaseline) { $null } else { Get-ScoutDriftProp $previous 'Statuses' } $counts = @{ New = 0; Resolved = 0; Regressed = 0; Unchanged = 0 } $driftFindings = [System.Collections.Generic.List[object]]::new() foreach ($id in $currentMap.Keys) { $f = $currentMap[$id] $currentStatus = Get-ScoutDriftProp $f 'Status' $prevStatus = if ($isBaseline) { $null } else { Get-ScoutDriftProp $previousStatuses $id } $existedBefore = ($null -ne $prevStatus) $driftType = if (-not $existedBefore) { 'New' } elseif ($prevStatus -in 'Fail', 'Partial' -and $currentStatus -eq 'Pass') { 'Resolved' } elseif ($prevStatus -eq 'Pass' -and $currentStatus -in 'Fail', 'Partial') { 'Regressed' } else { 'Unchanged' } $counts[$driftType]++ $driftFindings.Add([pscustomobject]@{ Id = $id Title = Get-ScoutDriftProp $f 'Title' Framework = Get-ScoutDriftProp $f 'Framework' Area = Get-ScoutDriftProp $f 'Area' PreviousStatus = $prevStatus CurrentStatus = $currentStatus Drift = $driftType }) } # Findings present in the previous run but absent from this one — not one # of the four required per-finding states, so surfaced only as a summary # count (e.g. a rule/area was retired between runs), never as a per- # finding Drift value outside the New/Resolved/Regressed/Unchanged set. $removedCount = 0 if (-not $isBaseline -and $null -ne $previousStatuses) { $prevIds = @($previousStatuses.PSObject.Properties.Name) $removedCount = @($prevIds | Where-Object { -not $currentMap.ContainsKey($_) }).Count } $currentScore = Get-ScoutOverallScore $Findings $previousScore = if ($isBaseline) { $null } else { Get-ScoutDriftProp $previous 'OverallScore' } $scoreDelta = if ($isBaseline -or $null -eq $currentScore -or $null -eq $previousScore) { $null } else { $currentScore - $previousScore } $result = [pscustomobject]@{ RunId = $RunId GeneratedOn = (Get-Date).ToString('o') IsBaseline = $isBaseline PreviousRunId = if ($isBaseline) { $null } else { Get-ScoutDriftProp $previous 'RunId' } OverallScore = $currentScore PreviousScore = $previousScore ScoreDelta = $scoreDelta Summary = [pscustomobject]@{ New = $counts['New'] Resolved = $counts['Resolved'] Regressed = $counts['Regressed'] Unchanged = $counts['Unchanged'] Removed = $removedCount } Findings = @($driftFindings | Sort-Object Framework, Area, Id) } # ---- persist this run's record (replace-on-rerun, else append) ---- $statusesForStorage = [ordered]@{} foreach ($id in $currentMap.Keys) { $statusesForStorage[$id] = Get-ScoutDriftProp $currentMap[$id] 'Status' } $newRecord = [pscustomobject]@{ RunId = $RunId Timestamp = (Get-Date).ToString('o') OverallScore = $currentScore Statuses = [pscustomobject]$statusesForStorage } $updatedHistory = @($history | Where-Object { (Get-ScoutDriftProp $_ 'RunId') -ne $RunId }) + $newRecord $updatedHistory | ConvertTo-Json -Depth 100 | Out-File $historyFile -Encoding utf8 return $result } |