diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 0249a54b..83945bfe 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -24,7 +24,13 @@ function Assert-IdleConditionPathsResolvable { [Parameter()] [AllowNull()] - [object] $WarningSink + [object] $WarningSink, + + # When set, skips validation of paths used by the Exists operator. + # Exists semantics intentionally allow missing paths (returns $false if absent), + # so strict execution-time path validation should exclude those paths. + [Parameter()] + [switch] $ExcludeExistsOperatorPaths ) function Add-IdlePathIfPresent { @@ -61,13 +67,16 @@ function Assert-IdleConditionPathsResolvable { [Parameter(Mandatory)] [AllowEmptyCollection()] - [System.Collections.Generic.List[string]] $PathList + [System.Collections.Generic.List[string]] $PathList, + + [Parameter()] + [switch] $ExcludeExistsPaths ) if ($Node.Contains('All')) { foreach ($child in @($Node.All)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -76,7 +85,7 @@ function Assert-IdleConditionPathsResolvable { if ($Node.Contains('Any')) { foreach ($child in @($Node.Any)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -85,7 +94,7 @@ function Assert-IdleConditionPathsResolvable { if ($Node.Contains('None')) { foreach ($child in @($Node.None)) { if ($child -is [System.Collections.IDictionary]) { - Get-IdleConditionPaths -Node $child -PathList $PathList + Get-IdleConditionPaths -Node $child -PathList $PathList -ExcludeExistsPaths:$ExcludeExistsPaths } } return @@ -102,12 +111,17 @@ function Assert-IdleConditionPathsResolvable { } if ($Node.Contains('Exists')) { - $existsVal = $Node.Exists - if ($existsVal -is [string]) { - Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal - } - elseif ($existsVal -is [System.Collections.IDictionary]) { - Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path + # Exists operator semantics: checking for the presence of a path is intentional. + # When -ExcludeExistsPaths is set (e.g. strict execution-time validation), skip these + # so that Exists can still return $false without causing a path-not-found error. + if (-not $ExcludeExistsPaths) { + $existsVal = $Node.Exists + if ($existsVal -is [string]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal + } + elseif ($existsVal -is [System.Collections.IDictionary]) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path + } } return } @@ -119,7 +133,7 @@ function Assert-IdleConditionPathsResolvable { } $paths = [System.Collections.Generic.List[string]]::new() - Get-IdleConditionPaths -Node $Condition -PathList $paths + Get-IdleConditionPaths -Node $Condition -PathList $paths -ExcludeExistsPaths:$ExcludeExistsOperatorPaths $uniquePaths = @($paths | Select-Object -Unique) if ($uniquePaths.Count -eq 0) { @@ -138,22 +152,26 @@ function Assert-IdleConditionPathsResolvable { } } - if ($softMissingContextPaths.Count -gt 0 -and $null -ne $WarningSink) { - $warningItem = [ordered]@{ - Code = 'PreconditionContextPathUnresolvedAtPlan' - Type = 'Warning' - Step = $StepName - Source = $Source - Paths = @($softMissingContextPaths | Select-Object -Unique) - Message = ("Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', @($softMissingContextPaths | Select-Object -Unique)))) - } + if ($softMissingContextPaths.Count -gt 0) { + $uniqueSoftPaths = @($softMissingContextPaths | Select-Object -Unique) + $warningMessage = "Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', $uniqueSoftPaths)) + + # Emit a visible PowerShell warning for immediate host feedback during planning. + Write-Warning $warningMessage + + if ($null -ne $WarningSink) { + $warningItem = [ordered]@{ + Code = 'PreconditionContextPathUnresolvedAtPlan' + Type = 'Warning' + Step = $StepName + Source = $Source + Paths = $uniqueSoftPaths + Message = $warningMessage + } - if ($WarningSink -is [System.Collections.IList]) { - $null = $WarningSink.Add($warningItem) - } - elseif ($WarningSink -is [object[]]) { - # Fallback for fixed arrays: cannot mutate by reference safely. - # Caller should pass an IList (plan.Warnings is an ArrayList) for collection. + if ($WarningSink -is [System.Collections.IList]) { + $null = $WarningSink.Add($warningItem) + } } } diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index e524ddbe..a17419ea 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -276,6 +276,23 @@ function ConvertTo-IdlePlanExportObject { $stepMap.inputs = $redactedInputs $stepMap.expectedState = $redactedExpectedState + # Per-step planning warnings (e.g. unresolved precondition context paths). + $rawStepWarnings = Get-FirstPropertyValue -Object $step -Names @('Warnings', 'PlanningWarnings', 'StepWarnings') + $stepWarningList = @() + foreach ($sw in @($rawStepWarnings)) { + if ($null -eq $sw) { continue } + + $stepWarningMap = New-OrderedMap + $stepWarningMap.code = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Code', 'code')) + $stepWarningMap.type = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Type', 'type')) + $stepWarningMap.source = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Source', 'source')) + $stepWarningMap.paths = Get-FirstPropertyValue -Object $sw -Names @('Paths', 'paths') + $stepWarningMap.message = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Message', 'message')) + + $stepWarningList += $stepWarningMap + } + $stepMap.warnings = $stepWarningList + $stepList += $stepMap } diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 64779e4c..48a5e741 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -128,10 +128,38 @@ function ConvertTo-IdleWorkflowSteps { $null } + $planWarnings = $null + $planObj = $PlanningContext.Plan + if ($null -ne $planObj) { + if ($planObj -is [System.Collections.IDictionary]) { + if ($planObj.Contains('Warnings')) { $planWarnings = $planObj['Warnings'] } + } else { + $wProp = $planObj.PSObject.Properties['Warnings'] + if ($null -ne $wProp) { $planWarnings = $wProp.Value } + } + } + $planWarningsCanTrackCount = $planWarnings -is [System.Collections.IList] + $warningCountBefore = if ($planWarningsCanTrackCount) { [int]$planWarnings.Count } else { 0 } + $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName -PlanningContext $PlanningContext $precondition = $preconditionSettings.Precondition $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse $preconditionEvent = $preconditionSettings.PreconditionEvent + $preconditionWarnings = @() + + if ($planWarningsCanTrackCount) { + $warningCountAfter = [int]$planWarnings.Count + if ($warningCountAfter -gt $warningCountBefore) { + for ($warningIndex = $warningCountBefore; $warningIndex -lt $warningCountAfter; $warningIndex++) { + $warning = $planWarnings[$warningIndex] + $warningSource = Get-IdlePropertyValue -Object $warning -Name 'Source' + $warningStep = Get-IdlePropertyValue -Object $warning -Name 'Step' + if ($warningSource -eq 'Precondition' -and $warningStep -eq $stepName) { + $preconditionWarnings += $warning + } + } + } + } $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' @@ -142,6 +170,7 @@ function ConvertTo-IdleWorkflowSteps { Precondition = $precondition OnPreconditionFalse = $onPreconditionFalse PreconditionEvent = $preconditionEvent + Warnings = $preconditionWarnings With = $with RequiresCapabilities = $requiresCaps Status = $status diff --git a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 index 634bc049..e956032e 100644 --- a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 @@ -70,7 +70,7 @@ function Get-IdleCapabilityContextPath { switch ($Capability) { 'IdLE.Entitlement.List' { return 'Identity.Entitlements' } - 'IdLE.Identity.Read' { return 'Identity.Profile' } + 'IdLE.Identity.Read' { return 'Identity.Profile' } default { throw [System.ArgumentException]::new( "No predefined context path defined for capability '$Capability'.", diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index f3484670..2374cca9 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -118,7 +118,7 @@ function Invoke-IdlePlanObject { # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries if ($null -ne $planProviders) { $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or - ($planProviders.PSObject -and $planProviders.PSObject.Properties) + ($planProviders.PSObject -and $planProviders.PSObject.Properties) if ($isValidProvider) { $effectiveProviders = $planProviders } @@ -330,8 +330,13 @@ function Invoke-IdlePlanObject { # Fail closed: a malformed or unexpected node type is treated as a failed precondition. $preconditionPassed = $false } - elseif (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) { - $preconditionPassed = $false + else { + # Validate that all non-Exists paths exist at execution time. + # Exists operator paths are excluded because Exists semantics intentionally allow missing paths. + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext -StepName $stepName -Source 'Precondition' -ExcludeExistsOperatorPaths + if (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) { + $preconditionPassed = $false + } } if (-not $preconditionPassed) { @@ -354,7 +359,7 @@ function Invoke-IdlePlanObject { $pcEvt = Get-IdlePropertyValue -Object $step -Name 'PreconditionEvent' if ($null -ne $pcEvt) { $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type') - $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') + $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data' # PreconditionEvent.Data is validated as a hashtable at planning time and # stored via Copy-IdleDataObject, so it will be a hashtable (IDictionary) here. @@ -626,8 +631,12 @@ function Invoke-IdlePlanObject { if ($ofPrecondition -isnot [System.Collections.IDictionary]) { $ofPreconditionPassed = $false } - elseif (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { - $ofPreconditionPassed = $false + else { + # Validate that all non-Exists paths exist at execution time. + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext -StepName $ofName -Source 'Precondition' -ExcludeExistsOperatorPaths + if (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { + $ofPreconditionPassed = $false + } } if (-not $ofPreconditionPassed) { @@ -650,7 +659,7 @@ function Invoke-IdlePlanObject { $ofPcEvt = Get-IdlePropertyValue -Object $ofStep -Name 'PreconditionEvent' if ($null -ne $ofPcEvt) { $ofPcEvtType = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Type') - $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') + $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') $ofPcEvtData = Get-IdlePropertyValue -Object $ofPcEvt -Name 'Data' $ofPcEvtDataHt = if ($ofPcEvtData -is [System.Collections.IDictionary]) { [hashtable]$ofPcEvtData } else { $null } $context.EventSink.WriteEvent($ofPcEvtType, $ofPcEvtMsg, $ofName, $ofPcEvtDataHt) diff --git a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 index 8a29b9dc..e4b709f3 100644 --- a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 +++ b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 @@ -370,10 +370,10 @@ function New-IdleAuthSessionBroker { # If multiple matches, this is ambiguous - fail with clear error if ($matchingEntries.Count -gt 1) { $matchDetails = ($matchingEntries | ForEach-Object { - $currentEntry = $_ - $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' - "{ $keyStr }" - }) -join '; ' + $currentEntry = $_ + $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' + "{ $keyStr }" + }) -join '; ' throw "Ambiguous auth session match for Name='$Name'. Multiple entries matched: $matchDetails. Provide AuthSessionOptions to disambiguate." } diff --git a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 index 77d8d096..48e6b915 100644 --- a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -100,8 +100,8 @@ function New-IdleRequestObject { # Clone hashtables to avoid external mutation after object creation # shallow clone is sufficient as we have already validated no ScriptBlocks are present $IdentityKeys = if ($null -eq $IdentityKeys) { @{} } else { $IdentityKeys.Clone() } - $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } - $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } + $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } + $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } # Construct and return the core domain object defined in Private/IdleLifecycleRequest.ps1 return [IdleLifecycleRequest]::new( diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 index 1e8cb77d..e42c0f70 100644 --- a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -46,88 +46,88 @@ function Get-IdleADAttributeContract { if ($Operation -eq 'CreateIdentity') { $contract = @{ # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Name Attributes - Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Password Attributes AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false } - AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } - AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # State Attributes - Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # Extension Container (keys must be valid LDAP attribute names) - OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } } } elseif ($Operation -eq 'EnsureAttributes') { $contract = @{ # Name Attributes - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Address Attributes - StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - City = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - State = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + City = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + State = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Web / Profile Attributes - HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Account / Profile Path Attributes - HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Extension Container (keys must be valid LDAP attribute names, e.g. 'mobile', 'telephoneNumber') OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 28b77786..461609ad 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -331,7 +331,7 @@ function New-IdleADAdapter { Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from DisplayName" } elseif ($effectiveAttributes.ContainsKey('GivenName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['GivenName']) -and - $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { + $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { $derivedName = "$($effectiveAttributes['GivenName']) $($effectiveAttributes['Surname'])" Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from GivenName+Surname" } diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 similarity index 91% rename from src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 rename to src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 index 43536e1b..3303a5de 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Format-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -1,7 +1,7 @@ -function Normalize-IdleExchangeOnlineAutoReplyMessage { +function Format-IdleExchangeOnlineAutoReplyMessage { <# .SYNOPSIS - Normalizes Exchange Online auto-reply messages for stable idempotency comparison. + Formats Exchange Online auto-reply messages for stable idempotency comparison. .DESCRIPTION Exchange Online may introduce server-side canonicalization when storing automatic reply messages, @@ -26,8 +26,8 @@ function Normalize-IdleExchangeOnlineAutoReplyMessage { System.String - The normalized message string. .EXAMPLE - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentMessage - if ($normalized -eq (Normalize-IdleExchangeOnlineAutoReplyMessage -Message $desiredMessage)) { + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentMessage + if ($normalized -eq (Format-IdleExchangeOnlineAutoReplyMessage -Message $desiredMessage)) { # Messages are functionally equivalent } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index be0b0aa2..2a6fb3cf 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -369,8 +369,8 @@ function New-IdleExchangeOnlineProvider { # Check internal message with normalization if ($Config.ContainsKey('InternalMessage')) { # Use normalization to handle server-side HTML canonicalization - $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage - $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] + $normalizedCurrent = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage + $normalizedDesired = Format-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] if ($normalizedCurrent -ne $normalizedDesired) { $changed = $true } @@ -379,8 +379,8 @@ function New-IdleExchangeOnlineProvider { # Check external message with normalization if ($Config.ContainsKey('ExternalMessage')) { # Use normalization to handle server-side HTML canonicalization - $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage - $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] + $normalizedCurrent = Format-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage + $normalizedDesired = Format-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] if ($normalizedCurrent -ne $normalizedDesired) { $changed = $true } @@ -456,9 +456,8 @@ function New-IdleExchangeOnlineProvider { $currentPerms = $this.Adapter.GetMailboxPermissions($mailboxSmtp, $accessToken) # Normalize current delegates (case-insensitive) - $currentFullAccessUsers = @($currentPerms | - Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | - ForEach-Object { $_.User.ToLowerInvariant() }) + $filteredFullAccessPerms = $currentPerms | Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } + $currentFullAccessUsers = @($filteredFullAccessPerms | ForEach-Object { $_.User.ToLowerInvariant() }) if ($hasEventSink) { $null = $this.EventSink.WriteEvent( @@ -505,9 +504,8 @@ function New-IdleExchangeOnlineProvider { if ($desiredSendAs.Count -gt 0) { $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) - $currentSendAsTrustees = @($currentRecipientPerms | - Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | - ForEach-Object { $_.Trustee.ToLowerInvariant() }) + $filteredSendAsPerms = $currentRecipientPerms | Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } + $currentSendAsTrustees = @($filteredSendAsPerms | ForEach-Object { $_.Trustee.ToLowerInvariant() }) if ($hasEventSink) { $null = $this.EventSink.WriteEvent( diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index 8d45f92c..bea1602f 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -114,11 +114,17 @@ Describe 'Export-IdlePlan' { $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers @($plan.Warnings).Count | Should -BeGreaterThan 0 + @($plan.Steps[0].Warnings).Count | Should -BeGreaterThan 0 + $plan.Steps[0].Warnings[0].Code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' $json = $plan | Export-IdlePlan | ConvertFrom-Json @($json.plan.warnings).Count | Should -BeGreaterThan 0 $json.plan.warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' $json.plan.warnings[0].step | Should -Be 'Check Context' + + $json.plan.steps[0].warnings | Should -Not -BeNullOrEmpty + ($json.plan.steps[0].warnings | Measure-Object).Count | Should -BeGreaterThan 0 + $json.plan.steps[0].warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' } } Context 'Contract invariants' { diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 8b8c19cb..5bee17cc 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -374,5 +374,24 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 } } + + Context 'Unresolvable precondition path at execution time' { + It 'throws when a non-Exists precondition path is missing from the request context at invoke time' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'missing-context-at-invoke.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.MissingContextAtInvoke' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.MissingContextAtInvoke') + } + + # Planning succeeds with a soft warning for the missing Request.Context path. + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + $plan | Should -Not -BeNullOrEmpty + @($plan.Warnings).Count | Should -BeGreaterThan 0 + + # Execution must throw because the path is still missing at runtime. + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*unresolved condition path*' + } + } } diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 29556a3a..05849d82 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -141,6 +141,49 @@ Describe 'New-IdlePlan' { $plan.OnFailureSteps[1].Type | Should -Be 'IdLE.Step.NeverApplicable' $plan.OnFailureSteps[1].Status | Should -Be 'NotApplicable' } + + It 'associates precondition warnings with the correct step even when Steps and OnFailureSteps share a name' { + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-shared-step-name-warning.psd1' -Content @' +@{ + Name = 'Joiner - Shared Step Name Warning' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'SharedName' + Type = 'IdLE.Step.ResolveIdentity' + Precondition = @{ Exists = 'Request.Context.MissingAtPlan' } + } + ) + OnFailureSteps = @( + @{ + Name = 'SharedName' + Type = 'IdLE.Step.Containment' + With = @{ Mode = 'Quarantine' } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + Dummy = $true + StepRegistry = @{ + 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @( + 'IdLE.Step.ResolveIdentity', + 'IdLE.Step.Containment' + ) + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + @($plan.Warnings).Count | Should -BeGreaterThan 0 + @($plan.Steps[0].Warnings).Count | Should -Be 1 + $plan.Steps[0].Warnings[0].Code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' + @($plan.OnFailureSteps[0].Warnings).Count | Should -Be 0 + } } Context 'Validation' { diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 5c934912..aff6119a 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -633,71 +633,72 @@ Describe 'ExchangeOnline provider - Unit tests' { } } - Context 'Normalize-IdleExchangeOnlineAutoReplyMessage' { + Context 'Format-IdleExchangeOnlineAutoReplyMessage' { BeforeAll { - # Import the private normalization function for direct testing + # Import the private auto-reply message normalization helper for direct testing. + # Named Format-* (approved PS verb) but performs normalization for idempotency comparison. $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent - $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Normalize-IdleExchangeOnlineAutoReplyMessage.ps1' - - if (-not (Test-Path -LiteralPath $normalizeFunctionPath -PathType Leaf)) { - throw "Normalize-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" + $formatHelperPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Format-IdleExchangeOnlineAutoReplyMessage.ps1' + + if (-not (Test-Path -LiteralPath $formatHelperPath -PathType Leaf)) { + throw "Format-IdleExchangeOnlineAutoReplyMessage script not found at: $formatHelperPath" } - - # Dot-source the private function - . $normalizeFunctionPath + + # Dot-source the private helper + . $formatHelperPath } It 'removes HTML wrappers' { $input = '
Test message
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'Test message
' } It 'normalizes CRLF to LF' { $input = "Line 1`r`nLine 2`r`nLine 3" - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be "Line 1`nLine 2`nLine 3" } It 'trims leading and trailing whitespace' { $input = "Test message
`n`n" - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'Test message
' } It 'normalizes excessive spaces conservatively' { $input = 'Test message here
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input # 3+ spaces become 2 spaces (conservative normalization) $normalized | Should -Be 'Test message here
' } It 'handles empty string input' { - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message '' + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message '' $normalized | Should -Be '' } It 'handles null input' { - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $null + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $null $normalized | Should -Be '' } It 'removes DOCTYPE declarations' { $input = 'Test
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'Test
' } It 'preserves intentional HTML formatting' { $input = 'This is important and contact us.
' - $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + $normalized = Format-IdleExchangeOnlineAutoReplyMessage -Message $input $normalized | Should -Be 'This is important and contact us.
' } diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index adbf1042..8c6a5095 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -33,7 +33,8 @@ "inputs": { "mailboxType": "User" }, - "expectedState": null + "expectedState": null, + "warnings": [] } ], "warnings": [] diff --git a/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 b/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 new file mode 100644 index 00000000..8737d0a3 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/missing-context-at-invoke.psd1 @@ -0,0 +1,15 @@ +@{ + Name = 'Missing Context Path At Invoke' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.MissingContextAtInvoke' + Precondition = @{ + All = @( + @{ In = @{ Path = 'Request.Context.NA'; Values = @('EU', 'DE') } } + ) + } + } + ) +} diff --git a/tools/Invoke-IdleScriptAnalyzer.ps1 b/tools/Invoke-IdleScriptAnalyzer.ps1 index b066495f..bf6ec6b1 100644 --- a/tools/Invoke-IdleScriptAnalyzer.ps1 +++ b/tools/Invoke-IdleScriptAnalyzer.ps1 @@ -179,13 +179,112 @@ function Initialize-Module { throw "Module '$Name' ($RequiredVersion) is required, but Install-Module is not available. Install the module manually and retry." } - Write-Host "Installing module '$Name' ($RequiredVersion) in CurrentUser scope..." + Write-Host " Installing module '$Name' ($RequiredVersion) in CurrentUser scope..." -ForegroundColor DarkGray Install-Module -Name $Name -Scope CurrentUser -Force -RequiredVersion $RequiredVersion -AllowClobber | Out-Null } Import-Module -Name $Name -RequiredVersion $RequiredVersion -Force } +function Write-PssaFinding { + <# + .SYNOPSIS + Writes a single PSScriptAnalyzer finding to the host with color coding. + + .DESCRIPTION + Errors are written in red, warnings in yellow. Suppresses raw diagnostic + object noise and formats the output for human readability. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $Finding + ) + + $severity = [string]$Finding.Severity + $color = if ($severity -eq 'Error') { 'Red' } else { 'Yellow' } + $icon = if ($severity -eq 'Error') { [char]0x2717 } else { [char]0x26A0 } + $label = if ($severity -eq 'Error') { 'Error ' } else { 'Warning' } + $scriptName = [System.IO.Path]::GetFileName([string]$Finding.ScriptPath) + + Write-Host " $icon [$label] $($Finding.RuleName)" -ForegroundColor $color + Write-Host " File : $scriptName (line $($Finding.Line), col $($Finding.Column))" -ForegroundColor DarkGray + Write-Host " Msg : $($Finding.Message)" -ForegroundColor DarkGray + Write-Host '' +} + +function Write-PssaHeader { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [version] $Version, + + [Parameter(Mandatory)] + [string] $SettingsPath, + + [Parameter(Mandatory)] + [string[]] $AnalyzedPaths + ) + + $separator = '-' * 64 + Write-Host $separator -ForegroundColor DarkCyan + Write-Host " PSScriptAnalyzer $Version" -ForegroundColor Cyan + Write-Host " Settings : $(Split-Path -Leaf $SettingsPath)" -ForegroundColor DarkCyan + Write-Host ' Paths :' -ForegroundColor DarkCyan + foreach ($p in $AnalyzedPaths) { + Write-Host " > $p" -ForegroundColor DarkGray + } + Write-Host $separator -ForegroundColor DarkCyan + Write-Host '' +} + +function Write-PssaSummary { + <# + .SYNOPSIS + Writes a color-coded summary of PSScriptAnalyzer results. + + .DESCRIPTION + - Green : no findings at all + - Yellow: only warnings present (local run passes, CI may flag if FailOnSeverity=Warning) + - Red : errors present (blocking findings) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]] $Findings, + + [Parameter(Mandatory)] + [string] $FailOnSeverity + ) + + $errorCount = @($Findings | Where-Object { $_.Severity -eq 'Error' }).Count + $warnCount = @($Findings | Where-Object { $_.Severity -eq 'Warning' }).Count + $separator = '-' * 64 + + Write-Host $separator -ForegroundColor DarkCyan + + if ($errorCount -eq 0 -and $warnCount -eq 0) { + Write-Host " $([char]0x2713) No findings — all checks passed." -ForegroundColor Green + } + else { + Write-Host " Findings : $($errorCount + $warnCount) total | $errorCount error(s) | $warnCount warning(s)" -ForegroundColor DarkGray + + if ($errorCount -gt 0) { + Write-Host " $([char]0x2717) $errorCount error(s) found — this run will FAIL." -ForegroundColor Red + } + + if ($warnCount -gt 0 -and $FailOnSeverity -eq 'Error') { + Write-Host " $([char]0x26A0) $warnCount warning(s) found — these pass locally but CI will flag them if FailOnSeverity=Warning." -ForegroundColor Yellow + } + elseif ($warnCount -gt 0) { + Write-Host " $([char]0x26A0) $warnCount warning(s) found — this run will FAIL." -ForegroundColor Yellow + } + } + + Write-Host $separator -ForegroundColor DarkCyan + Write-Host '' +} + function Write-JsonFile { <# .SYNOPSIS @@ -250,15 +349,18 @@ Initialize-Module -Name 'PSScriptAnalyzer' -RequiredVersion $PSScriptAnalyzerVer # Run analysis using the repo settings file. # We rely on the settings file for rule selection and severities. -Write-Host "Running PSScriptAnalyzer ($PSScriptAnalyzerVersion) using settings: $resolvedSettingsPath" -Write-Host "Analyzing paths:" -$resolvedPaths | ForEach-Object { Write-Host " - $_" } +Write-PssaHeader -Version $PSScriptAnalyzerVersion -SettingsPath $resolvedSettingsPath -AnalyzedPaths $resolvedPaths $findings = @() foreach ($path in $resolvedPaths) { $findings += Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $resolvedSettingsPath } +# Display each finding with color coding (errors in red, warnings in yellow). +foreach ($f in ($findings | Sort-Object ScriptName, Line, Column, RuleName)) { + Write-PssaFinding -Finding $f +} + # Create a stable, small JSON payload (DiagnosticRecord contains complex members). $summary = @( foreach ($f in ($findings | Sort-Object ScriptName, Line, Column, RuleName)) { @@ -274,7 +376,7 @@ $summary = @( ) if ($CI -and $resolvedJsonOutputPath) { - Write-Host "Writing PSScriptAnalyzer JSON results: $resolvedJsonOutputPath" + Write-Host " Writing JSON results : $resolvedJsonOutputPath" -ForegroundColor DarkGray Write-JsonFile -Path $resolvedJsonOutputPath -Object $summary } @@ -288,10 +390,13 @@ if ($emitSarif -and $resolvedSarifOutputPath) { throw "ConvertToSARIF module is installed, but 'ConvertTo-SARIF' cmdlet was not found." } - Write-Host "Writing SARIF results: $resolvedSarifOutputPath" + Write-Host " Writing SARIF results : $resolvedSarifOutputPath" -ForegroundColor DarkGray $findings | & $convertCommand -FilePath $resolvedSarifOutputPath } +# Display color-coded summary (green/yellow/red) with CI hint for warnings. +Write-PssaSummary -Findings $findings -FailOnSeverity $FailOnSeverity + # Determine whether we should fail this run. $failSeverities = @($FailOnSeverity) if ($FailOnSeverity -eq 'Warning') { @@ -308,5 +413,3 @@ if ($blockingFindings) { $message = "PSScriptAnalyzer found blocking issues (FailOnSeverity: $FailOnSeverity). Errors: $errorCount, Warnings: $warningCount." throw $message } - -Write-Host "PSScriptAnalyzer completed with no blocking findings (FailOnSeverity: $FailOnSeverity)."