Public/Get-OffsetDrift.ps1

function Get-OffsetDrift {
    <#
    .SYNOPSIS
        Analyzes a drift journal to explain how a file's detectability changed over time.

    .DESCRIPTION
        Reads the NDJSON journal written by Add-OffsetDriftEntry, groups snapshots by file, and
        for each consecutive pair explains the change: whether detection moved because the FILE
        changed (different SHA-256), because Defender's SIGNATURES changed (different signature
        version), or for neither reason (a non-deterministic provider result). This is the
        direct answer to "it was detected before and now it isn't - why?".

        Read-only and cross-platform.

    .PARAMETER JournalPath
        NDJSON journal to read. Defaults to OffsetInspect\drift.ndjson under LocalApplicationData.

    .PARAMETER FilePath
        Only analyze snapshots for this file (matched by canonical full path).

    .PARAMETER FileSha256
        Only analyze snapshots whose recorded SHA-256 matches.

    .EXAMPLE
        Get-OffsetDrift -FilePath .\sample.ps1 | Select-Object -ExpandProperty Transitions

    .EXAMPLE
        Get-OffsetDrift | Where-Object EverChanged
    #>

    [CmdletBinding()]
    [OutputType('OffsetInspect.DriftReport')]
    param(
        [ValidateNotNullOrEmpty()]
        [string]$JournalPath = (Join-Path ([System.Environment]::GetFolderPath('LocalApplicationData')) 'OffsetInspect/drift.ndjson'),

        [AllowNull()]
        [string]$FilePath,

        [AllowNull()]
        [string]$FileSha256
    )

    if (-not (Test-Path -LiteralPath $JournalPath)) {
        throw "Drift journal not found: $JournalPath"
    }

    $filterPath = $null
    if (-not [string]::IsNullOrWhiteSpace($FilePath)) {
        $filterPath = try { [System.IO.Path]::GetFullPath($FilePath) } catch { $FilePath }
    }

    $records = New-Object 'System.Collections.Generic.List[object]'
    foreach ($line in [System.IO.File]::ReadAllLines($JournalPath)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        # Each line is a single JSON object, so ConvertFrom-Json is edition-safe here.
        try { $obj = ConvertFrom-Json -InputObject $line }
        catch { Write-Verbose 'Skipping malformed journal line.'; continue }
        if ($null -eq $obj) { continue }

        $recFile = [string](Get-OIResultProperty -InputObject $obj -Name 'File')
        if ($null -ne $filterPath) {
            $recFull = try { [System.IO.Path]::GetFullPath($recFile) } catch { $recFile }
            if ($recFull -ne $filterPath) { continue }
        }
        if (-not [string]::IsNullOrWhiteSpace($FileSha256)) {
            if ([string](Get-OIResultProperty -InputObject $obj -Name 'FileSha256') -ne $FileSha256) { continue }
        }
        $records.Add($obj)
    }

    if ($records.Count -eq 0) { return }

    $groups = $records | Group-Object -Property { $f = Get-OIResultProperty -InputObject $_ -Name 'File'; try { [System.IO.Path]::GetFullPath([string]$f) } catch { [string]$f } }
    foreach ($group in $groups) {
        $ordered = @($group.Group | Sort-Object -Property TimestampUtc)
        Compare-OIDriftTimeline -Snapshots $ordered
    }
}