Private/Kinds/StabilityChange.ps1

# The tie between a Change and crashes: a part of the Stability Kind, and no Kind.
#
# An order dependency, and it exists by position only: the Check of the Change Kind
# ("Recent changes" in the Local Definitions) has to stand BEFORE the Stability Check in
# the Check Definitions, because a Gatherer is handed only what the Checks before it
# gathered. Where it stands after it, or was skipped, or is not in the Definitions at all,
# nothing breaks and nothing is said: the crash Findings are then exactly as they were
# before there was a Change Kind, with no line about a Change and no Finding saying that
# none was looked for (#59: "the crash Finding is then as today"). A test holds the Local
# Definitions to the order: tests/Definition.Tests.ps1, "stand in the order the Checks that
# read from each other need". A Check Definition document cannot carry a comment, so that
# test and this are where the dependency is written down.

# How many days before the first crash of one kind a Change may lie and still be named
# beside it. The spec's (#59): "where crashes of that kind began within 3 days after a
# Change". A rule of thumb and no limit of Windows: a driver that crashes a machine
# usually does so within days of being installed, and after a week half the programs of
# a machine have updated themselves.
$script:StabilityChangeWindowDays = 3

# How many days before the first crash of one kind have to have been read, and without a
# crash of that kind, for the crashes to be said to have BEGUN there. A rule of thumb and
# no limit of Windows: two weeks, because what crashes every week has crashed in them, and
# so has what crashes within days of each monthly update at the one before. With the
# window alone - three readable days before the first crash in the data - both were said
# to have begun after a Change. See Get-CrashChangeTie.
$script:StabilityChangeQuietDays = 14

# On how many different days of the Changes' window the same thing has to have been
# changed to be routine, which no crash is tied to. Two: what Windows logged on two days
# of one month comes back by itself. A rule of thumb. See Select-ChangeWorthTying.
$script:StabilityChangeRoutineDays = 2

# The updates no crash is tied to, by the number of their article, which is the same in
# every language. KB2267602 is the security intelligence update of Microsoft Defender
# Antivirus: the virus definitions, installed once a day or more, under that one number.
$script:StabilityChangeIgnoredUpdates = @('KB2267602')

# What the title of an update looks like that is an app from the Microsoft Store updating
# itself: the Store's id of the app, twelve characters beginning with 9, a hyphen, and the
# name of the package ("9WZDNCRFJBH4-Microsoft.Windows.Photos"). Read off real events on
# Windows 11, where Windows Update logs a dozen of these a month, at night, each app under
# a title of its own - so that none of them recurs, and one of them is near every crash.
$script:StabilityChangeStoreAppPattern = '^9[A-Z0-9]{11}-\S'

# The kinds of Change a crash can be tied to. Not a start of Windows and not an
# unexpected shutdown: those are what happened to the machine, logged at the start after,
# and nothing that was done to it. A kind this list does not know is not tied.
$script:StabilityChangeTiedKinds = @(
    'driver-installed', 'program-installed', 'program-updated', 'program-removed', 'program-listed',
    'service-added', 'windows-update'
)

function Copy-ObservedChange {
    <#
    .SYNOPSIS
        The Changes the Change Check gathered, as this Kind's own data. Pure, and decides
        nothing.
    .DESCRIPTION
        Every Change, the starts of Windows and the daily virus definitions with the
        rest: what a crash is tied to is decided by the Judge, which a Check Definition
        can then tell otherwise without anything being gathered again.
 
        Gathered is false when that Check was not performed. With them go how far back
        the Changes were read and how far each log reaches: a crash is only said to have
        begun after a Change where the days before it were read.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Observed)

    $source = Get-ObservedKindData -Observed $Observed -Kind 'Change'

    [pscustomobject]@{
        Gathered   = $null -ne $source
        GatheredAt = Get-DataProperty $source 'GatheredAt'
        WindowDays = Get-DataProperty $source 'WindowDays'
        Since      = Get-DataProperty $source 'Since'
        Logs       = @(foreach ($row in (Get-DataCollection $source 'Logs')) {
            [pscustomobject]@{
                Log      = "$(Get-DataProperty $row 'Log')"
                Readable = [bool](Get-DataProperty $row 'Readable')
                Oldest   = Get-DataProperty $row 'Oldest'
            }
        })
        Changes    = @(foreach ($row in (Get-DataCollection $source 'Changes')) {
            [pscustomobject]@{
                Time         = Get-DataProperty $row 'Time'
                Kind         = "$(Get-DataProperty $row 'Kind')"
                What         = "$(Get-DataProperty $row 'What')"
                Version      = "$(Get-DataProperty $row 'Version')"
                Account      = "$(Get-DataProperty $row 'Account')"
                AccountState = "$(Get-DataProperty $row 'AccountState')"
                DateOnly     = [bool](Get-DataProperty $row 'DateOnly')
                Before       = "$(Get-DataProperty $row 'Before')"
                Detail       = "$(Get-DataProperty $row 'Detail')"
            }
        })
    }
}

function Get-ChangeTieSetting {
    <#
    .SYNOPSIS
        How a crash is tied to a Change: how many days before the first crash a Change
        may lie, how many days before it have to have been quiet, from how many days on
        a recurring Change is routine, and which updates are never tied. Pure.
    .DESCRIPTION
        Check Definition parameters: ChangeWindowDays (3), ChangeQuietDays (14),
        ChangeRoutineDays and ChangeIgnoredUpdates. What is no number, or not above
        zero, is the Kind's own: a slip in a Definition must neither throw nor switch
        the question off.
 
        Both numbers of days are rules of thumb and neither is a limit of Windows or one
        Microsoft documents: see where their defaults are declared. The quiet time is
        never shorter than the window: a Change is only looked for in days that were read.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Parameters = @{})

    $window = Get-NumberParameter $Parameters 'ChangeWindowDays' $script:StabilityChangeWindowDays
    if ($window -le 0) { $window = $script:StabilityChangeWindowDays }

    $quiet = Get-NumberParameter $Parameters 'ChangeQuietDays' $script:StabilityChangeQuietDays
    if ($quiet -le 0) { $quiet = $script:StabilityChangeQuietDays }
    if ($quiet -lt $window) { $quiet = $window }

    $routine = Get-NumberParameter $Parameters 'ChangeRoutineDays' $script:StabilityChangeRoutineDays
    if ($routine -lt 2) { $routine = $script:StabilityChangeRoutineDays }

    [pscustomobject]@{
        WindowDays     = $window
        QuietDays      = $quiet
        RoutineDays    = $routine
        IgnoredUpdates = @(Get-ListParameter $Parameters 'ChangeIgnoredUpdates' $script:StabilityChangeIgnoredUpdates)
    }
}

function Get-ChangeRoutineKey {
    <#
    .SYNOPSIS
        What a Change is a Change of, to know the same thing changed again. Pure.
    .DESCRIPTION
        A program by its name without its numbers, whether installed, updated or removed:
        many carry their version in their name. An update by the number of its article,
        which every edition of the virus definitions shares. A driver by its device and
        its version: the same version given to a device again is the device plugged in
        again, and another version is news. A service by the file it runs, which stays
        where a program adds its service under a new name and in a new folder each time:
        see Get-ChangeServiceFile, which is the Change Kind's.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Change)

    $kind = "$(Get-DataProperty $Change 'Kind')"
    $what = "$(Get-DataProperty $Change 'What')"
    switch -Wildcard ($kind) {
        'program-*'        { return 'program|{0}' -f (Get-ChangeProgramKey $what) }
        'windows-update'   {
            if ($what -match 'KB\d{6,}') { return ('update|{0}' -f $Matches[0]).ToLowerInvariant() }
            return 'update|{0}' -f (Get-ChangeProgramKey $what)
        }
        'driver-installed' { return ('driver|{0}|{1}' -f $what, (Get-DataProperty $Change 'Version')).ToLowerInvariant() }
        'service-added'    {
            $file = Get-ChangeServiceFile -Change $Change
            if ($file) { return 'service|{0}' -f $file }
            return 'service|{0}' -f (Get-ChangeProgramKey $what)
        }
    }
    ('{0}|{1}' -f $kind, $what).ToLowerInvariant()
}

function Test-ChangeNamesProgram {
    <#
    .SYNOPSIS
        Whether a Change is plausibly to the program that crashes: a program of its name,
        or a service that runs its file. Pure.
    .DESCRIPTION
        By the letters of the two names and nothing else: "labelwin.exe" is in "Acme
        Labelwin 7.1". A name of fewer than four letters is in too many others to say
        anything. A driver and an update name a device and an article, never a program.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Change, [AllowNull()][AllowEmptyString()][string]$App)

    $file = "$App".Trim().ToLowerInvariant()
    if (-not $file) { return $false }

    $kind = "$(Get-DataProperty $Change 'Kind')"
    if ($kind -eq 'service-added' -and (Get-ChangeServiceFile -Change $Change) -eq $file) { return $true }
    if ($kind -ne 'service-added' -and $kind -notlike 'program-*') { return $false }

    $program = (($file -replace '\.exe$', '') -replace '[^\p{L}]', '')
    $named   = ("$(Get-DataProperty $Change 'What')".ToLowerInvariant() -replace '[^\p{L}]', '')
    if ($program.Length -lt 4 -or $named.Length -lt 4) { return $false }
    [bool]($named.Contains($program) -or $program.Contains($named))
}

function Select-ChangeWorthTying {
    <#
    .SYNOPSIS
        The Changes a crash can be tied to, each with its time and whether it names the
        crashing program. Pure.
    .DESCRIPTION
        A tie that stands in every Report is worse than none: a Technician who has read
        "began after a Change" on three healthy machines does not read it on the fourth.
        So what is tied is what was done to the machine and does not happen by itself:
 
          - a driver installed, a program installed, updated or removed by Windows
            Installer, a service added, an update Windows Update installed;
          - never a start of Windows or an unexpected shutdown, which are no Changes a
            crash comes from, see $script:StabilityChangeTiedKinds;
          - never the virus definitions, see $script:StabilityChangeIgnoredUpdates, and
            never an app from the Microsoft Store updating itself, see
            $script:StabilityChangeStoreAppPattern;
          - never what was changed on RoutineDays or more different days of the Changes'
            window, see Get-ChangeRoutineKey: a browser that updates itself every week, a
            service a program adds again at every start. Twice on one day is not that:
            an installation and its patch;
          - never a program known only from the list of installed programs, by its day:
            the list keeps one date for each program, the last, so it cannot show that a
            program updates itself every week, and those that do are the ones in it.
            What such a program brings that can stop Windows - a driver, a service - is
            logged as a Change of its own.
 
        What names the crashing program is tied whatever the last two say: that the
        program that crashes was itself changed is the first thing to know, routine or not.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Change,
        [AllowNull()][AllowEmptyString()][string]$App = '',
        [AllowNull()]$Setting
    )

    if ($null -eq $Setting) { $Setting = Get-ChangeTieSetting }

    $rows = @(foreach ($row in (Get-DataCollection $Change 'Changes')) {
        $time = ConvertTo-DataTime (Get-DataProperty $row 'Time')
        if ($null -eq $time) { continue }
        [pscustomobject]@{ Row = $row; Time = $time; Kind = "$(Get-DataProperty $row 'Kind')"; Key = Get-ChangeRoutineKey -Change $row }
    })

    # On how many different days each thing was changed.
    $days = @{}
    foreach ($entry in $rows) {
        if (-not $days.ContainsKey($entry.Key)) { $days[$entry.Key] = @{} }
        $days[$entry.Key][$entry.Time.ToString('yyyyMMdd')] = $true
    }

    foreach ($entry in $rows) {
        if ($script:StabilityChangeTiedKinds -notcontains $entry.Kind) { continue }

        # Not asked whether it names the program: an update names an article or a package.
        if ($entry.Kind -eq 'windows-update' -and
            "$(Get-DataProperty $entry.Row 'What')".Trim() -cmatch $script:StabilityChangeStoreAppPattern) { continue }

        $related = Test-ChangeNamesProgram -Change $entry.Row -App $App
        if (-not $related) {
            if ($entry.Kind -eq 'program-listed') { continue }
            if ($entry.Kind -eq 'windows-update') {
                $title = "$(Get-DataProperty $entry.Row 'What')"
                if (@($Setting.IgnoredUpdates | Where-Object { $_ -and $title -match ('(?i)\b{0}\b' -f [regex]::Escape($_)) }).Count) { continue }
            }
            if ($days[$entry.Key].Count -ge $Setting.RoutineDays) { continue }
        }

        [pscustomobject]@{
            Change   = $entry.Row
            Time     = $entry.Time
            DateOnly = [bool](Get-DataProperty $entry.Row 'DateOnly')
            Related  = $related
        }
    }
}

function Get-CrashChangeTie {
    <#
    .SYNOPSIS
        The Change the crashes of one kind began shortly after, or nothing. Pure.
    .DESCRIPTION
        The crashes of one kind are the blue screens with one stop code, or the crashes
        of one program. They began with the earliest of them in the data. Where a Change
        worth tying (Select-ChangeWorthTying) lies no more than ChangeWindowDays before
        that first crash, the crashes are tied to it: to the nearest before the crash,
        and Others says how many more lie in the same days. For a program, a Change that
        names it comes before a nearer one that does not, and is not required: a driver
        installed before a program's first crash is exactly what a Technician would not
        have looked for.
 
        A Change known by its day only is compared by day, and only a day strictly
        before that of the first crash counts. On the day of the crash itself the order
        of the two is not known, and "began after a Change" is not said of an order
        nobody knows.
 
        Nothing where the Changes were not read: the Check was not performed, or none of
        its logs could be read. Where it was performed and one of its logs could not be
        read, or does not reach back over the window before the first crash, a Change
        from another log is still tied - and Partial says that not every source of
        Changes was read for that time, so that "the nearest" is the nearest of what
        was read and is not taken for more.
 
        Nothing where "began" cannot be known. The first crash in the data is the first
        crash only if a quiet time before it was read: ChangeQuietDays (14) without a
        crash of that kind. The first crash in the data has none before it in the data,
        so what has to hold is that the data reaches back that far before it - by this
        Check (Days back from when the Changes were gathered, which is minutes before),
        by the Change Check (Since), and in the log the crashes are in, where the Change
        Check says how far back that log reaches. A first crash nearer than that to any
        of those may have had one before it that nobody saw: a program that crashes
        every week, or with every monthly update, has its "first" crash in the data
        within days of whatever was changed last, and was said to have begun there as
        long as three readable days were enough. Where the data does not reach back
        that far, nothing is said.
    .PARAMETER Time
        When each crash of the kind happened.
    .PARAMETER Log
        The event log these crashes are in: System for blue screens, Application for programs.
    .PARAMETER Days
        How many days this Check looked back.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Time,
        [AllowNull()]$Change,
        [AllowNull()][AllowEmptyString()][string]$App = '',
        [AllowNull()][AllowEmptyString()][string]$Log = '',
        [AllowNull()]$Days,
        [AllowNull()][hashtable]$Parameters = @{}
    )

    if (-not [bool](Get-DataProperty $Change 'Gathered')) { return }
    $logs = Get-DataCollection $Change 'Logs'
    if (-not @($logs | Where-Object { [bool](Get-DataProperty $_ 'Readable') }).Count) { return }

    $times = @(@($Time) | ForEach-Object { ConvertTo-DataTime $_ } | Where-Object { $null -ne $_ } | Sort-Object)
    if (-not $times.Count) { return }
    $first = [datetime]$times[0]

    $setting = Get-ChangeTieSetting -Parameters $Parameters
    $from    = $first.AddDays(-$setting.WindowDays)
    # From where on the days before the first crash have to have been read.
    $quiet   = $first.AddDays(-$setting.QuietDays)

    # How far back everything was read. See the description.
    $reach = ConvertTo-DataTime (Get-DataProperty $Change 'Since')
    if ($null -eq $reach) { return }
    $gathered = ConvertTo-DataTime (Get-DataProperty $Change 'GatheredAt')
    $looked   = ConvertTo-Number $Days
    if ($null -ne $gathered -and $null -ne $looked -and $gathered.AddDays(-$looked) -gt $reach) { $reach = $gathered.AddDays(-$looked) }
    foreach ($entry in @($logs | Where-Object { "$(Get-DataProperty $_ 'Log')" -eq $Log })) {
        $oldest = ConvertTo-DataTime (Get-DataProperty $entry 'Oldest')
        if ($null -ne $oldest -and $oldest -gt $reach) { $reach = $oldest }
    }
    if ($quiet -lt $reach) { return }

    $near = @(Select-ChangeWorthTying -Change $Change -App $App -Setting $setting | ForEach-Object {
        if ($_.DateOnly) {
            # A day strictly before: on the day of the crash the order is not known.
            $apart = ($first.Date - $_.Time.Date).TotalDays
            if ($apart -lt 1 -or $apart -gt $setting.WindowDays) { return }
        }
        elseif ($_.Time -gt $first -or $_.Time -lt $from) { return }
        $_
    })
    if (-not $near.Count) { return }

    $named = @($near | Where-Object { $_.Related })
    if (-not $named.Count) { $named = $near }
    # On every column that tells two apart: a sort does not keep the order of equals on
    # Windows PowerShell 5.1.
    $nearest = @($named | Sort-Object @{ Expression = { $_.Time }; Descending = $true },
        @{ Expression = { "$(Get-DataProperty $_.Change 'Kind')" } }, @{ Expression = { "$(Get-DataProperty $_.Change 'What')" } })[0]

    # Whether every log a Change comes from was read over the window before the crash.
    $partial = [bool]@($logs | Where-Object {
        if (-not [bool](Get-DataProperty $_ 'Readable')) { return $true }
        $oldest = ConvertTo-DataTime (Get-DataProperty $_ 'Oldest')
        $null -ne $oldest -and $oldest -gt $from
    }).Count

    [pscustomobject]@{
        First    = $first
        Change   = $nearest.Change
        Time     = $nearest.Time
        Partial  = $partial
        DateOnly = $nearest.DateOnly
        # For a Change known by its day only: how many days before the day of the crash.
        Days     = [int]($first.Date - $nearest.Time.Date).TotalDays
        Related  = $nearest.Related
        Others   = $near.Count - 1
        # The title of the timeline takes the days it covers.
        Timeline = Get-DataProperty $Change 'WindowDays'
    }
}

function Get-ChangeTieText {
    <#
    .SYNOPSIS
        How long after which Change the first crash was, as the end of a sentence: "2
        Tage nach einer Aenderung: Treiber installiert, ..., am ... (Konto ...)". Pure.
    .DESCRIPTION
        The Change by its kind in words, its name, its version and the one before where
        Windows logged them, when, and the account where Windows logged one. Where it
        logged none, none is named: the timeline says which of the two it was.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Tie)

    $change = $Tie.Change
    $time   = [datetime]$Tie.Time

    if ($Tie.DateOnly) {
        # At least one: a Change of the day of the crash itself is not tied.
        $after = Get-Text 'Value.Stability.Change.After.Days' $Tie.Days
        if ($Tie.Days -le 1) { $after = Get-Text 'Value.Stability.Change.After.Day' }
    }
    else {
        $apart = [datetime]$Tie.First - $time
        $hours = [int][math]::Floor($apart.TotalHours)
        $days  = [int][math]::Round($apart.TotalDays, [MidpointRounding]::AwayFromZero)
        if ($hours -lt 1)      { $after = Get-Text 'Value.Stability.Change.After.Minutes' }
        elseif ($hours -eq 1)  { $after = Get-Text 'Value.Stability.Change.After.Hour' }
        elseif ($hours -lt 24) { $after = Get-Text 'Value.Stability.Change.After.Hours' $hours }
        elseif ($days -le 1)   { $after = Get-Text 'Value.Stability.Change.After.Day' }
        else                   { $after = Get-Text 'Value.Stability.Change.After.Days' $days }
    }

    $parts = @((Get-ChangeKindText -Kind "$(Get-DataProperty $change 'Kind')"), "$(Get-DataProperty $change 'What')")
    $version = "$(Get-DataProperty $change 'Version')"
    $before  = "$(Get-DataProperty $change 'Before')"
    if ($version) { $parts += Get-Text 'Value.Stability.Change.Version' $version }
    if ($before)  { $parts += Get-Text 'Value.Stability.Change.VersionBefore' $before }
    if ($Tie.DateOnly) { $parts += Get-Text 'Value.Stability.Change.On' $time }
    else               { $parts += Get-Text 'Value.Stability.Change.At' $time }
    $named = @($parts | Where-Object { $_ }) -join ', '

    $account = "$(Get-DataProperty $change 'Account')"
    if ($account) { $named = Get-Text 'Value.Stability.Change.Account' $named $account }

    if ($Tie.Others -eq 1)    { $named = Get-Text 'Value.Stability.Change.OtherOne' $named }
    elseif ($Tie.Others -gt 1) { $named = Get-Text 'Value.Stability.Change.Others' $named $Tie.Others }

    $text = Get-Text 'Value.Stability.Change.After' $after $named
    # One short statement, so that "the nearest" is not read as more than it is.
    if ([bool](Get-DataProperty $Tie 'Partial')) { $text = '{0}; {1}' -f $text, (Get-Text 'Value.Stability.Change.SourcesPartial') }
    $text
}

function Get-ChangeTieMeaning {
    <#
    .SYNOPSIS
        What it means that crashes began after a Change, said once for a Finding however
        many of its crashes were tied: a coincidence in time, and no proven cause. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Tie)

    $ties = @($Tie | Where-Object { $null -ne $_ })
    if (-not $ties.Count) { return }
    Get-Text 'Meaning.Stability.Change.Coincidence'
    if (@($ties | Where-Object { $_.Related }).Count) { Get-Text 'Meaning.Stability.Change.SameProgram' }
}

function Get-ChangeTieHint {
    <#
    .SYNOPSIS
        What to do about crashes that began after a Change: take that kind of Change back,
        or bring it up to date, as a test. Each said once. Pure.
    .DESCRIPTION
        Not so for an update of Windows: a coincidence in time is no reason for first
        level to uninstall a security update. First whether problems with it are known
        and whether other machines with it show the same; uninstalling comes after, as a
        test and with second level.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Tie)

    $hints = @(foreach ($one in @($Tie | Where-Object { $null -ne $_ })) {
        switch ("$(Get-DataProperty $one.Change 'Kind')") {
            'driver-installed' { Get-Text 'Hint.Stability.Change.Driver' }
            'windows-update'   { Get-Text 'Hint.Stability.Change.Update' }
            'service-added'    { Get-Text 'Hint.Stability.Change.Service' }
            'program-removed'  { Get-Text 'Hint.Stability.Change.ProgramRemoved' }
            default            { Get-Text 'Hint.Stability.Change.Program' }
        }
    })
    $hints | Select-Object -Unique
}

function Get-ChangeTieReference {
    <#
    .SYNOPSIS
        The Reference to the timeline of the Changes, for a Finding with a crash tied to
        one; nothing for a Finding without. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Tie)

    $ties = @($Tie | Where-Object { $null -ne $_ })
    if (-not $ties.Count) { return }
    # As the Change Kind names its Section: by the days its window has.
    $days = ConvertTo-Number $ties[0].Timeline
    if ($null -eq $days) { $days = $script:ChangeDefaultWindowDays }
    New-Reference -Section 'Title.Change.Timeline' -Argument $days
}