diff --git a/modules/events/diagnostic.ps1 b/modules/events/diagnostic.ps1 index 00ad403..2179266 100644 --- a/modules/events/diagnostic.ps1 +++ b/modules/events/diagnostic.ps1 @@ -115,16 +115,7 @@ function Group-EventLogEvents { $levelDisplayName = [string]$level } - $representativeMessage = [string]$representativeEvent.Message - if ([string]::IsNullOrWhiteSpace($representativeMessage)) { - $representativeMessage = 'No message' - } - else { - $representativeMessage = (($representativeMessage -replace '\s+', ' ').Trim()) - if ($representativeMessage.Length -gt 240) { - $representativeMessage = $representativeMessage.Substring(0, 237) + '...' - } - } + $representativeMessage = ConvertTo-OneLineMessage -Message ([string]$representativeEvent.Message) $logNames = @( $orderedEvents | diff --git a/modules/performance/diagnostic.ps1 b/modules/performance/diagnostic.ps1 index 4dba1a9..be9c032 100644 --- a/modules/performance/diagnostic.ps1 +++ b/modules/performance/diagnostic.ps1 @@ -309,7 +309,7 @@ function Invoke-PerformanceSnapshot { else { Write-Host 'Method : average of 3 short samples' Write-Host ('Total CPU load : {0:N1}%' -f $cpu.Percent) - Write-Host ('Samples : {0}' -f (($cpu.Samples | ForEach-Object { '{0:N1}%' -f $_ }) -join ', ')) + Write-Host ('Samples : {0}' -f (($cpu.Samples | ForEach-Object { '{0:N1}%' -f $_ }) -join ', ')) } Write-Section 'Pagefile Usage' diff --git a/modules/services/diagnostic.ps1 b/modules/services/diagnostic.ps1 index 9e1a168..cf24a95 100644 --- a/modules/services/diagnostic.ps1 +++ b/modules/services/diagnostic.ps1 @@ -235,15 +235,15 @@ function Get-StartupRegistrySourceInventory { if ($null -eq $ApprovalInventory) { $ApprovalInventory = Get-StartupApprovalInventory -Path $Definition.ApprovalLocation } - if (-not [string]::IsNullOrWhiteSpace([string]$approvalInventory.Error)) { - $errors.Add([pscustomobject]@{ Path = $approvalInventory.Path; Error = $approvalInventory.Error }) + if (-not [string]::IsNullOrWhiteSpace([string]$ApprovalInventory.Error)) { + $errors.Add([pscustomobject]@{ Path = $ApprovalInventory.Path; Error = $ApprovalInventory.Error }) } try { if (Test-Path -LiteralPath $Definition.Location) { $item = Get-ItemProperty -LiteralPath $Definition.Location -ErrorAction Stop foreach ($property in @($item.PSObject.Properties | Where-Object { $_.Name -notin @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider') })) { - $state = Resolve-StartupEntryState -Name $property.Name -ApprovalInventory $approvalInventory + $state = Resolve-StartupEntryState -Name $property.Name -ApprovalInventory $ApprovalInventory $entries.Add([pscustomobject]@{ State = $state.State StateSource = $state.StateSource @@ -273,14 +273,14 @@ function Get-StartupFolderSourceInventory { if ($null -eq $ApprovalInventory) { $ApprovalInventory = Get-StartupApprovalInventory -Path $Definition.ApprovalLocation } - if (-not [string]::IsNullOrWhiteSpace([string]$approvalInventory.Error)) { - $errors.Add([pscustomobject]@{ Path = $approvalInventory.Path; Error = $approvalInventory.Error }) + if (-not [string]::IsNullOrWhiteSpace([string]$ApprovalInventory.Error)) { + $errors.Add([pscustomobject]@{ Path = $ApprovalInventory.Path; Error = $ApprovalInventory.Error }) } try { if (Test-Path -LiteralPath $Definition.Location -PathType Container) { foreach ($file in @(Get-ChildItem -LiteralPath $Definition.Location -Force -File -ErrorAction Stop)) { - $state = Resolve-StartupEntryState -Name $file.Name -ApprovalInventory $approvalInventory + $state = Resolve-StartupEntryState -Name $file.Name -ApprovalInventory $ApprovalInventory $entries.Add([pscustomobject]@{ State = $state.State StateSource = $state.StateSource @@ -536,7 +536,7 @@ else { Write-Host ('Total services : {0}' -f $services.Count) Write-Host ('Running services : {0}' -f $runningServices.Count) Write-Host ('Auto + Stopped (Indeterminate) : {0}' -f $automaticNotRunning.Count) - Write-Host ('Pending states (Suspicious) : {0}' -f $suspiciousServiceStates.Count) + Write-Host ('Pending states (Suspicious) : {0}' -f $suspiciousServiceStates.Count) Write-Host ('Confirmed service problems : {0}' -f $confirmedServiceProblems.Count) } diff --git a/scripts/process-runner.ps1 b/scripts/process-runner.ps1 index c102a77..0153b22 100644 --- a/scripts/process-runner.ps1 +++ b/scripts/process-runner.ps1 @@ -253,11 +253,11 @@ function Test-WdtSnapshotMembership { } if ($identity.Status -ne $script:WdtCleanupStatus.Match) { return $identity } if ($cursor.IsRoot) { return New-WdtStatusResult -Status $script:WdtCleanupStatus.Match } - if (-not $SnapshotById.ContainsKey([int]$cursor.ParentProcessId)) { return New-WdtStatusResult -Status $script:WdtCleanupStatus.SnapshotParentMissing -Message 'Expected parent is absent from the cleanup snapshot.' } - $cursor = $SnapshotById[[int]$cursor.ParentProcessId] + $parentId = [int]$cursor.ParentProcessId + if (-not $SnapshotById.ContainsKey($parentId) -or $null -eq $SnapshotById[$parentId]) { return New-WdtStatusResult -Status $script:WdtCleanupStatus.SnapshotParentMissing -Message 'Expected parent is absent from the cleanup snapshot.' } + $cursor = $SnapshotById[$parentId] $isTarget = $false } - return New-WdtStatusResult -Status $script:WdtCleanupStatus.SnapshotParentMissing -Message 'Expected parent is absent from the cleanup snapshot.' } function Get-WdtProcessCleanupSummary { diff --git a/scripts/report-common.ps1 b/scripts/report-common.ps1 index 788d298..a6e9e93 100644 --- a/scripts/report-common.ps1 +++ b/scripts/report-common.ps1 @@ -153,15 +153,15 @@ function Protect-WdtRegexMatches { [scriptblock]$TokenValueSelector ) - $matches = $Regex.Matches($Text) - if ($matches.Count -eq 0) { + $regexMatches = $Regex.Matches($Text) + if ($regexMatches.Count -eq 0) { return $Text } $builder = New-Object System.Text.StringBuilder $nextIndex = 0 - foreach ($match in $matches) { + foreach ($match in $regexMatches) { $valueMatch = $match if (-not [string]::IsNullOrWhiteSpace($CaptureGroupName)) { $valueMatch = $match.Groups[$CaptureGroupName] @@ -784,9 +784,9 @@ function Get-WdtFindingsSummary { return [pscustomobject][ordered]@{ OverallStatus = $overallStatus - ErrorCount = $errors.Count - WarningCount = $warnings.Count - OkModuleCount = $okItems.Count - Items = @($errors + $warnings + $okItems) + ErrorCount = $errors.Count + WarningCount = $warnings.Count + OkModuleCount = $okItems.Count + Items = @($errors + $warnings + $okItems) } } diff --git a/scripts/tui.ps1 b/scripts/tui.ps1 index f8046cf..0d649c7 100644 --- a/scripts/tui.ps1 +++ b/scripts/tui.ps1 @@ -332,29 +332,23 @@ function Join-WdtTuiColumns { function Test-WdtTuiUnicodeLogoSupport { param( [bool]$IsOutputRedirected, - [bool]$HasWtSession, - [string]$TermProgram, [string]$OutputEncodingWebName ) if ($IsOutputRedirected) { return $false } - $hasUtf8Output = $OutputEncodingWebName -in @('utf-8', 'utf8') - $hasWindowsTerminalSignal = $HasWtSession -or $TermProgram -eq 'Windows_Terminal' - return $hasUtf8Output -and ($hasWindowsTerminalSignal -or $hasUtf8Output) + return $OutputEncodingWebName -in @('utf-8', 'utf8') } function Get-WdtTuiLogoModeDecision { param( [bool]$IsOutputRedirected, - [bool]$HasWtSession, - [string]$TermProgram, [string]$OutputEncodingWebName, [string]$Override ) if ($Override -ieq 'ascii') { return 'Ascii' } if ($Override -ieq 'unicode') { return $(if ($IsOutputRedirected) { 'Ascii' } else { 'Unicode' }) } - if (Test-WdtTuiUnicodeLogoSupport -IsOutputRedirected $IsOutputRedirected -HasWtSession $HasWtSession -TermProgram $TermProgram -OutputEncodingWebName $OutputEncodingWebName) { + if (Test-WdtTuiUnicodeLogoSupport -IsOutputRedirected $IsOutputRedirected -OutputEncodingWebName $OutputEncodingWebName) { return 'Unicode' } return 'Ascii' @@ -368,7 +362,7 @@ function Get-WdtTuiLogoMode { if ($override -ine 'unicode') { $encodingWebName = [System.Console]::OutputEncoding.WebName } - return Get-WdtTuiLogoModeDecision -IsOutputRedirected $isOutputRedirected -HasWtSession (-not [string]::IsNullOrWhiteSpace($env:WT_SESSION)) -TermProgram ([string]$env:TERM_PROGRAM) -OutputEncodingWebName $encodingWebName -Override $override + return Get-WdtTuiLogoModeDecision -IsOutputRedirected $isOutputRedirected -OutputEncodingWebName $encodingWebName -Override $override } catch { } return 'Ascii' @@ -1187,7 +1181,7 @@ function Invoke-WdtInteractiveSession { $inputEvent = Wait-WdtTuiEvent -InitialWidth $size.Width -InitialHeight $size.Height if ($inputEvent.Type -eq 'Resize') { Reset-WdtTuiFrame - $layout = Show-WdtTuiScreen -State $state -Width $inputEvent.Size.Width -Height $inputEvent.Size.Height -ShowItemNumbers $false -ForceFull $true + $null = Show-WdtTuiScreen -State $state -Width $inputEvent.Size.Width -Height $inputEvent.Size.Height -ShowItemNumbers $false -ForceFull $true continue } $keyInfo = $inputEvent.KeyInfo diff --git a/scripts/validation-policy.ps1 b/scripts/validation-policy.ps1 index 7de8522..7f0bccb 100644 --- a/scripts/validation-policy.ps1 +++ b/scripts/validation-policy.ps1 @@ -302,8 +302,7 @@ function Test-WdtAllowedW32tmProcessShape { param( [Parameter(Mandatory = $true)][System.Management.Automation.Language.Ast]$Ast, [string]$ScriptPath, - [string]$RepositoryRoot, - [string[]]$LocalFunctionNames + [string]$RepositoryRoot ) if (-not (Test-WdtScriptPath $ScriptPath $RepositoryRoot 'modules\time\diagnostic.ps1')) { return $false } @@ -468,7 +467,7 @@ function Get-WdtNewObjectTypeName { } function Test-WdtAllowedNewObjectCommand { - param($CommandAst, [string]$ScriptPath, [string]$RepositoryRoot, [string[]]$LocalFunctionNames) + param($CommandAst, [string]$ScriptPath, [string]$RepositoryRoot) if ($CommandAst.Redirections.Count -ne 0) { return $false } $elements = @($CommandAst.CommandElements) @@ -528,7 +527,7 @@ function Test-WdtAllowedNewObjectCommand { (Test-WdtScriptPath $ScriptPath $RepositoryRoot 'modules\time\diagnostic.ps1') -and (Get-WdtEnclosingFunctionName $CommandAst) -ceq 'Invoke-W32tmQuery' -and $CommandAst.CommandElements.Count -eq 2 -and - (Test-WdtAllowedW32tmProcessShape -Ast $CommandAst -ScriptPath $ScriptPath -RepositoryRoot $RepositoryRoot -LocalFunctionNames $LocalFunctionNames)) { + (Test-WdtAllowedW32tmProcessShape -Ast $CommandAst -ScriptPath $ScriptPath -RepositoryRoot $RepositoryRoot)) { return $true } @@ -723,7 +722,7 @@ function Get-WdtCommandSafetyIssue { } if ($leaf -eq 'New-Object') { - if (Test-WdtAllowedNewObjectCommand $CommandAst $ScriptPath $RepositoryRoot $LocalFunctionNames) { return } + if (Test-WdtAllowedNewObjectCommand $CommandAst $ScriptPath $RepositoryRoot) { return } return New-WdtSafetyIssue $CommandAst 'New-Object type is not in the reviewed safe-type allowlist.' } @@ -752,7 +751,7 @@ function Get-WdtCommandSafetyIssue { } function Get-WdtMemberSafetyIssue { - param($MemberAst, [string]$ScriptPath, [string]$RepositoryRoot, [string[]]$LocalFunctionNames) + param($MemberAst, [string]$ScriptPath, [string]$RepositoryRoot) $member = [string]$MemberAst.Member.Value $argumentCount = $MemberAst.Arguments.Count $isStatic = $MemberAst.Expression -is [System.Management.Automation.Language.TypeExpressionAst] @@ -889,7 +888,7 @@ function Get-WdtMemberSafetyIssue { $member -in @('ConnectAsync', 'Wait', 'Dispose')) { return } if ((Test-WdtScriptPath $ScriptPath $RepositoryRoot 'modules\time\diagnostic.ps1') -and (Get-WdtEnclosingFunctionName $MemberAst) -ceq 'Invoke-W32tmQuery' -and - (Test-WdtAllowedW32tmProcessShape -Ast $MemberAst -ScriptPath $ScriptPath -RepositoryRoot $RepositoryRoot -LocalFunctionNames $LocalFunctionNames) -and + (Test-WdtAllowedW32tmProcessShape -Ast $MemberAst -ScriptPath $ScriptPath -RepositoryRoot $RepositoryRoot) -and (($member -eq 'Start' -and $receiver -ceq 'process' -and $argumentCount -eq 0) -or ($member -eq 'Dispose' -and $receiver -in @('process', 'stdoutReader', 'stderrReader') -and $argumentCount -eq 0))) { return @@ -986,7 +985,7 @@ function Get-WdtSafetyIssues { } $localFunctionNames = @($localFunctionNames | Sort-Object -Unique) foreach ($command in @($Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true))) { Get-WdtCommandSafetyIssue $command $ScriptPath $RepositoryRoot $localFunctionNames $RegistrySnapshot $ModuleDefinition } - foreach ($member in @($Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] }, $true))) { Get-WdtMemberSafetyIssue $member $ScriptPath $RepositoryRoot $localFunctionNames } + foreach ($member in @($Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] }, $true))) { Get-WdtMemberSafetyIssue $member $ScriptPath $RepositoryRoot } foreach ($member in @($Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.MemberExpressionAst] -and $n -isnot [System.Management.Automation.Language.InvokeMemberExpressionAst] }, $true))) { Get-WdtConsolePropertySafetyIssue $member $ScriptPath $RepositoryRoot } foreach ($variable in @($Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] -and $n.VariablePath.UserPath -like 'function:*' }, $true))) { New-WdtSafetyIssue $variable 'Dynamic function provider access is not allowed in production scripts.' diff --git a/tests/diagnostic-classification.tests.ps1 b/tests/diagnostic-classification.tests.ps1 index 35d2f26..c7b9026 100644 --- a/tests/diagnostic-classification.tests.ps1 +++ b/tests/diagnostic-classification.tests.ps1 @@ -21,7 +21,7 @@ $now = Get-Date # Event Log fixtures: severity alone is context; exact rules, grouping, cutoff, # provider identity, and partial source availability are deterministic. $eventsScript = Join-Path $repositoryRoot 'modules\events\diagnostic.ps1' -Import-TestFunctions $eventsScript @('Get-EventSignalRule','Group-EventLogEvents','Read-EventLog') +Import-TestFunctions $eventsScript @('ConvertTo-OneLineMessage','Get-EventSignalRule','Group-EventLogEvents','Read-EventLog') Assert-True ($null -eq (Get-EventSignalRule 'Application' 'Fixture-Provider' 1000 2)) 'A generic Error event must remain context.' Assert-True ($null -eq (Get-EventSignalRule 'System' 'Microsoft-Windows-DistributedCOM' 10016 2)) 'Expected DCOM 10016 noise must remain context.' Assert-True ($null -eq (Get-EventSignalRule 'System' 'Fixture-Kernel-Power' 41 1)) 'Event ID 41 from another provider must not match.' @@ -44,6 +44,18 @@ Assert-Equal 3 $repeatedEventGroup.Count 'Repeated events must be grouped.' Assert-Equal 'representative' $repeatedEventGroup.RepresentativeMessage 'The latest event must provide the representative message.' Assert-True (-not $repeatedEventGroup.IsSignal) 'A grouped generic Error event must not become a finding.' +$messageFixtures = @( + [pscustomobject]@{ ProviderName='Fixture-Whitespace'; Id=7100; Level=2; LevelDisplayName='Error'; LogName='Application'; TimeCreated=$now.AddHours(-1); Message=" `r`n`t "; RecordId=6 }, + [pscustomobject]@{ ProviderName='Fixture-LongMessage'; Id=7101; Level=2; LevelDisplayName='Error'; LogName='Application'; TimeCreated=$now.AddHours(-1); Message=("line1`r`nline2 " + ('x' * 300)); RecordId=7 } +) +$messageGroups = @(Group-EventLogEvents $messageFixtures $eventCutoff) +$whitespaceGroup = @($messageGroups | Where-Object { $_.ProviderName -eq 'Fixture-Whitespace' })[0] +Assert-Equal 'No message' $whitespaceGroup.RepresentativeMessage 'A whitespace-only message must normalize to No message.' +$longGroup = @($messageGroups | Where-Object { $_.ProviderName -eq 'Fixture-LongMessage' })[0] +Assert-Equal 240 $longGroup.RepresentativeMessage.Length 'A long message must be truncated to 240 characters.' +Assert-True ($longGroup.RepresentativeMessage.EndsWith('...')) 'A truncated message must end with an ellipsis.' +Assert-True ($longGroup.RepresentativeMessage.StartsWith('line1 line2 ')) 'Message whitespace runs must collapse to single spaces.' + function script:Get-WinEvent { [CmdletBinding()] param([hashtable]$FilterHashtable, [int]$MaxEvents) diff --git a/tests/process-runner.tests.ps1 b/tests/process-runner.tests.ps1 index 988b6dd..0cf3472 100644 --- a/tests/process-runner.tests.ps1 +++ b/tests/process-runner.tests.ps1 @@ -35,6 +35,10 @@ $childEntry = [pscustomobject]@{ ProcessId=200; ParentProcessId=100; Depth=1; Is $watch = [Diagnostics.Stopwatch]::StartNew() $membership = & { function Get-CimInstance { param($ClassName,$Filter,$OperationTimeoutSec) @() }; Test-WdtSnapshotMembership $childEntry @{100=$rootEntry;200=$childEntry} $watch 500 } Assert-Equal 'TargetNotFound' $membership.Status 'An exited descendant must be safe.' +$nullParentWatch = [Diagnostics.Stopwatch]::StartNew() +$nullParentMembership = & { function Get-CimInstance { param($ClassName,$Filter,$OperationTimeoutSec) [pscustomobject]@{ ProcessId=200; ParentProcessId=100; CreationDate=[datetime]'2024-01-01T00:00:01' } }; Test-WdtSnapshotMembership $childEntry @{100=$null;200=$childEntry} $nullParentWatch 500 } +Assert-True ($null -ne $nullParentMembership) 'A null parent snapshot value must still produce a structured result.' +Assert-Equal 'SnapshotParentMissing' $nullParentMembership.Status 'A null parent snapshot value must be structured as SnapshotParentMissing.' $failedSummary = Get-WdtProcessCleanupSummary @([pscustomobject]@{Status='TerminationFailed'}) @('fixture') $false Assert-Equal $false $failedSummary.Success 'Failed cleanup must be explicit.' diff --git a/tests/tui.tests.ps1 b/tests/tui.tests.ps1 index 3f52fea..7e63fd0 100644 --- a/tests/tui.tests.ps1 +++ b/tests/tui.tests.ps1 @@ -249,4 +249,11 @@ try { ConvertTo-WdtReportParameters -State $state -RegistrySnapshot $otherSnapsh catch { $snapshotMismatchBlocked = $true } Assert-True $snapshotMismatchBlocked 'Report conversion must reject a different registry snapshot.' +Assert-Equal 'Ascii' (Get-WdtTuiLogoModeDecision -IsOutputRedirected $false -OutputEncodingWebName 'utf-8' -Override 'ascii') 'The ascii override must force the ASCII logo.' +Assert-Equal 'Unicode' (Get-WdtTuiLogoModeDecision -IsOutputRedirected $false -OutputEncodingWebName 'ibm866' -Override 'unicode') 'The unicode override must apply in an interactive host.' +Assert-Equal 'Ascii' (Get-WdtTuiLogoModeDecision -IsOutputRedirected $true -OutputEncodingWebName 'utf-8' -Override 'unicode') 'The unicode override must stay ASCII for redirected output.' +Assert-Equal 'Unicode' (Get-WdtTuiLogoModeDecision -IsOutputRedirected $false -OutputEncodingWebName 'utf-8' -Override '') 'Interactive UTF-8 output must select the Unicode logo.' +Assert-Equal 'Ascii' (Get-WdtTuiLogoModeDecision -IsOutputRedirected $true -OutputEncodingWebName 'utf-8' -Override '') 'Redirected output must select the ASCII logo.' +Assert-Equal 'Ascii' (Get-WdtTuiLogoModeDecision -IsOutputRedirected $false -OutputEncodingWebName 'ibm866' -Override '') 'An OEM encoding must select the ASCII logo.' + Write-Host 'TUI snapshot, dynamic viewport, layout, and parameter conversion tests passed.'