From 7a3aebb40df6988afc4fae238f47439282016010 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:37:32 +0100 Subject: [PATCH 01/13] Centralize step and precondition workflow validation logic --- ...o-IdleWorkflowStepPreconditionSettings.ps1 | 110 ++++++++++++ .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 87 +-------- .../Private/Test-IdleWorkflowSchema.ps1 | 166 +++++++++--------- ...WorkflowStepPreconditionSettings.Tests.ps1 | 57 ++++++ tests/Core/Test-IdleWorkflowSchema.Tests.ps1 | 87 +++++++++ 5 files changed, 341 insertions(+), 166 deletions(-) create mode 100644 src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 create mode 100644 tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 create mode 100644 tests/Core/Test-IdleWorkflowSchema.Tests.ps1 diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 new file mode 100644 index 00000000..21375f3d --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -0,0 +1,110 @@ +Set-StrictMode -Version Latest + +function ConvertTo-IdleWorkflowStepPreconditionSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName + ) + + $normalized = @{ + Preconditions = $null + OnPreconditionFalse = $null + PreconditionEvent = $null + } + + # Runtime Preconditions: evaluated at execution time (not planning time). + # Each precondition uses the same declarative condition DSL as Condition. + if (Test-IdleWorkflowStepKey -Step $Step -Key 'Preconditions') { + $rawPreconditions = Get-IdlePropertyValue -Object $Step -Name 'Preconditions' + if ($null -ne $rawPreconditions) { + $pcList = @($rawPreconditions) + for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { + $pc = $pcList[$pcIdx] + if ($pc -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $StepName, $pcIdx), + 'Workflow' + ) + } + + $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$pc) -StepName $StepName + if (@($pcErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $StepName, ([string]::Join(' ', @($pcErrors)))), + 'Workflow' + ) + } + } + + $normalized.Preconditions = @() + foreach ($pc in $pcList) { + $normalized.Preconditions += Copy-IdleDataObject -Value $pc + } + } + } + + if (Test-IdleWorkflowStepKey -Step $Step -Key 'OnPreconditionFalse') { + $rawOnPreconditionFalseValue = Get-IdlePropertyValue -Object $Step -Name 'OnPreconditionFalse' + if ($null -ne $rawOnPreconditionFalseValue) { + $rawOnPreconditionFalse = [string]$rawOnPreconditionFalseValue + if (-not [string]::IsNullOrWhiteSpace($rawOnPreconditionFalse)) { + if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $StepName, $rawOnPreconditionFalse), + 'Workflow' + ) + } + + $normalized.OnPreconditionFalse = $rawOnPreconditionFalse + } + } + } + + if (Test-IdleWorkflowStepKey -Step $Step -Key 'PreconditionEvent') { + $rawPreconditionEvent = Get-IdlePropertyValue -Object $Step -Name 'PreconditionEvent' + if ($null -ne $rawPreconditionEvent) { + if ($rawPreconditionEvent -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent must be a hashtable." -f $StepName), + 'Workflow' + ) + } + + $pcEvtType = if ($rawPreconditionEvent.Contains('Type')) { [string]$rawPreconditionEvent['Type'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtType)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Type is required and must be a non-empty string." -f $StepName), + 'Workflow' + ) + } + + $pcEvtMsg = if ($rawPreconditionEvent.Contains('Message')) { [string]$rawPreconditionEvent['Message'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtMsg)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Message is required and must be a non-empty string." -f $StepName), + 'Workflow' + ) + } + + # PreconditionEvent.Data is optional but must be a hashtable if present. + if ($rawPreconditionEvent.Contains('Data') -and $null -ne $rawPreconditionEvent['Data']) { + if ($rawPreconditionEvent['Data'] -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Data must be a hashtable." -f $StepName), + 'Workflow' + ) + } + } + + $normalized.PreconditionEvent = Copy-IdleDataObject -Value $rawPreconditionEvent + } + } + + return $normalized +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 22da8a13..2306e568 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -126,89 +126,10 @@ function ConvertTo-IdleWorkflowSteps { $null } - # Runtime Preconditions: evaluated at execution time (not planning time). - # Each precondition uses the same declarative condition DSL as Condition. - $preconditions = $null - if (Test-IdleWorkflowStepKey -Step $s -Key 'Preconditions') { - $rawPreconditions = Get-IdlePropertyValue -Object $s -Name 'Preconditions' - if ($null -ne $rawPreconditions) { - $pcList = @($rawPreconditions) - for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { - $pc = $pcList[$pcIdx] - if ($pc -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $stepName, $pcIdx), - 'Workflow' - ) - } - $pcErrors = Test-IdleConditionSchema -Condition $pc -StepName $stepName - if (@($pcErrors).Count -gt 0) { - throw [System.ArgumentException]::new( - ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $stepName, ([string]::Join(' ', @($pcErrors)))), - 'Workflow' - ) - } - } - $preconditions = @() - foreach ($pc in $pcList) { - $preconditions += Copy-IdleDataObject -Value $pc - } - } - } - - $onPreconditionFalse = $null - if (Test-IdleWorkflowStepKey -Step $s -Key 'OnPreconditionFalse') { - $rawOnPreconditionFalseValue = Get-IdlePropertyValue -Object $s -Name 'OnPreconditionFalse' - if ($null -ne $rawOnPreconditionFalseValue) { - $rawOnPreconditionFalse = [string]$rawOnPreconditionFalseValue - if (-not [string]::IsNullOrWhiteSpace($rawOnPreconditionFalse)) { - if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), - 'Workflow' - ) - } - $onPreconditionFalse = $rawOnPreconditionFalse - } - } - } - - $preconditionEvent = $null - if (Test-IdleWorkflowStepKey -Step $s -Key 'PreconditionEvent') { - $rawPreconditionEvent = Get-IdlePropertyValue -Object $s -Name 'PreconditionEvent' - if ($null -ne $rawPreconditionEvent) { - if ($rawPreconditionEvent -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent must be a hashtable." -f $stepName), - 'Workflow' - ) - } - $pcEvtType = if ($rawPreconditionEvent.Contains('Type')) { [string]$rawPreconditionEvent['Type'] } else { $null } - if ([string]::IsNullOrWhiteSpace($pcEvtType)) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent.Type is required and must be a non-empty string." -f $stepName), - 'Workflow' - ) - } - $pcEvtMsg = if ($rawPreconditionEvent.Contains('Message')) { [string]$rawPreconditionEvent['Message'] } else { $null } - if ([string]::IsNullOrWhiteSpace($pcEvtMsg)) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent.Message is required and must be a non-empty string." -f $stepName), - 'Workflow' - ) - } - # PreconditionEvent.Data is optional but must be a hashtable if present. - if ($rawPreconditionEvent.Contains('Data') -and $null -ne $rawPreconditionEvent['Data']) { - if ($rawPreconditionEvent['Data'] -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': PreconditionEvent.Data must be a hashtable." -f $stepName), - 'Workflow' - ) - } - } - $preconditionEvent = Copy-IdleDataObject -Value $rawPreconditionEvent - } - } + $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName + $preconditions = $preconditionSettings.Preconditions + $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse + $preconditionEvent = $preconditionSettings.PreconditionEvent $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 767fb7e3..5f6f8ebd 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -83,7 +83,14 @@ function Test-IdleWorkflowSchema { foreach ($pc in @($Step.Preconditions)) { if ($pc -isnot [System.Collections.IDictionary]) { $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' must be a hashtable (condition node).") + $pcIdx++ + continue } + + foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$pc) -StepName $StepPath)) { + $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' has invalid condition schema: $schemaError") + } + $pcIdx++ } } @@ -115,129 +122,122 @@ function Test-IdleWorkflowSchema { } } - $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers') - foreach ($key in $Workflow.Keys) { - if ($allowedRootKeys -notcontains $key) { - $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") + function Test-IdleWorkflowStepCondition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Step, + + [Parameter(Mandatory)] + [string] $StepPath, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + # Conditions use the same declarative condition DSL as runtime preconditions. + if ($Step.ContainsKey('Condition') -and $null -ne $Step.Condition) { + if ($Step.Condition -isnot [System.Collections.IDictionary]) { + $ErrorList.Add("'$StepPath.Condition' must be a hashtable (declarative condition object).") + } + else { + foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Condition) -StepName $StepPath)) { + $ErrorList.Add("'$StepPath.Condition' has invalid condition schema: $schemaError") + } + } } } - if (-not $Workflow.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$Workflow.Name)) { - $errors.Add("Missing or empty required root key 'Name'.") - } + function Test-IdleWorkflowStepCollection { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $StepCollection, - if (-not $Workflow.ContainsKey('LifecycleEvent') -or [string]::IsNullOrWhiteSpace([string]$Workflow.LifecycleEvent)) { - $errors.Add("Missing or empty required root key 'LifecycleEvent'.") - } + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $CollectionName, + + [Parameter(Mandatory)] + [string] $DuplicateNameErrorTemplate, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + if ($StepCollection -isnot [System.Collections.IEnumerable] -or $StepCollection -is [string]) { + $ErrorList.Add("'$CollectionName' must be an array/list of step hashtables.") + return + } - if (-not $Workflow.ContainsKey('Steps') -or $null -eq $Workflow.Steps) { - $errors.Add("Missing required root key 'Steps'.") - } - elseif ($Workflow.Steps -isnot [System.Collections.IEnumerable] -or $Workflow.Steps -is [string]) { - $errors.Add("'Steps' must be an array/list of step hashtables.") - } - else { $stepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $i = 0 - foreach ($step in $Workflow.Steps) { - $stepPath = "Steps[$i]" + foreach ($step in $StepCollection) { + $stepPath = "{0}[{1}]" -f $CollectionName, $i if ($null -eq $step -or $step -isnot [hashtable]) { - $errors.Add("$stepPath must be a hashtable.") + $ErrorList.Add("$stepPath must be a hashtable.") $i++ continue } - Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $errors + Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $ErrorList if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { - $errors.Add("Missing or empty required key '$stepPath.Name'.") + $ErrorList.Add("Missing or empty required key '$stepPath.Name'.") } else { if (-not $stepNames.Add([string]$step.Name)) { - $errors.Add("Duplicate step name '$($step.Name)' detected. Step names must be unique.") + $ErrorList.Add(($DuplicateNameErrorTemplate -f [string]$step.Name)) } } if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) { - $errors.Add("Missing or empty required key '$stepPath.Type'.") + $ErrorList.Add("Missing or empty required key '$stepPath.Type'.") } - # Conditions must be declarative data, never a ScriptBlock/expression. - # We only enforce the shape here; semantic validation comes later. - if ($step.ContainsKey('Condition') -and $null -ne $step.Condition -and $step.Condition -isnot [hashtable]) { - $errors.Add("'$stepPath.Condition' must be a hashtable (declarative condition object).") - } + Test-IdleWorkflowStepCondition -Step $step -StepPath $stepPath -ErrorList $ErrorList # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) { - $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") + $ErrorList.Add("'$stepPath.With' must be a hashtable (step parameters).") } - # Validate RetryProfile - Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors - - # Validate Preconditions, OnPreconditionFalse, PreconditionEvent - Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $ErrorList + Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $ErrorList $i++ } } - # OnFailureSteps are optional. If present, validate them like regular Steps. - if ($Workflow.ContainsKey('OnFailureSteps') -and $null -ne $Workflow.OnFailureSteps) { - if ($Workflow.OnFailureSteps -isnot [System.Collections.IEnumerable] -or $Workflow.OnFailureSteps -is [string]) { - $errors.Add("'OnFailureSteps' must be an array/list of step hashtables.") + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers') + foreach ($key in $Workflow.Keys) { + if ($allowedRootKeys -notcontains $key) { + $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") } - else { - $failureStepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - - $i = 0 - foreach ($step in $Workflow.OnFailureSteps) { - $stepPath = "OnFailureSteps[$i]" - - if ($null -eq $step -or $step -isnot [hashtable]) { - $errors.Add("$stepPath must be a hashtable.") - $i++ - continue - } - - Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $errors - - if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { - $errors.Add("Missing or empty required key '$stepPath.Name'.") - } - else { - if (-not $failureStepNames.Add([string]$step.Name)) { - $errors.Add("Duplicate step name '$($step.Name)' detected in 'OnFailureSteps'. Step names must be unique within this collection.") - } - } - - if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) { - $errors.Add("Missing or empty required key '$stepPath.Type'.") - } - - # Conditions must be declarative data, never a ScriptBlock/expression. - # We only enforce the shape here; semantic validation comes later. - if ($step.ContainsKey('Condition') -and $null -ne $step.Condition -and $step.Condition -isnot [hashtable]) { - $errors.Add("'$stepPath.Condition' must be a hashtable (declarative condition object).") - } + } - # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. - if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) { - $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") - } + if (-not $Workflow.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$Workflow.Name)) { + $errors.Add("Missing or empty required root key 'Name'.") + } - # Validate RetryProfile - Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors + if (-not $Workflow.ContainsKey('LifecycleEvent') -or [string]::IsNullOrWhiteSpace([string]$Workflow.LifecycleEvent)) { + $errors.Add("Missing or empty required root key 'LifecycleEvent'.") + } - # Validate Preconditions, OnPreconditionFalse, PreconditionEvent - Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + if (-not $Workflow.ContainsKey('Steps') -or $null -eq $Workflow.Steps) { + $errors.Add("Missing required root key 'Steps'.") + } + else { + Test-IdleWorkflowStepCollection -StepCollection $Workflow.Steps -CollectionName 'Steps' -DuplicateNameErrorTemplate "Duplicate step name '{0}' detected. Step names must be unique." -ErrorList $errors + } - $i++ - } - } + # OnFailureSteps are optional. If present, validate them like regular Steps. + if ($Workflow.ContainsKey('OnFailureSteps') -and $null -ne $Workflow.OnFailureSteps) { + Test-IdleWorkflowStepCollection -StepCollection $Workflow.OnFailureSteps -CollectionName 'OnFailureSteps' -DuplicateNameErrorTemplate "Duplicate step name '{0}' detected in 'OnFailureSteps'. Step names must be unique within this collection." -ErrorList $errors } # ContextResolvers are optional. If present, validate each resolver entry. diff --git a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 new file mode 100644 index 00000000..9b0a10d7 --- /dev/null +++ b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 @@ -0,0 +1,57 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { + InModuleScope 'IdLE.Core' { + It 'returns null values when the step does not define precondition settings' { + $step = @{ Name = 'Noop'; Type = 'IdLE.Step.Noop' } + + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'Noop' + + $result.Preconditions | Should -BeNullOrEmpty + $result.OnPreconditionFalse | Should -BeNullOrEmpty + $result.PreconditionEvent | Should -BeNullOrEmpty + } + + It 'normalizes valid precondition settings and deep-copies the data' { + $step = @{ + Name = 'GuardedStep' + Type = 'IdLE.Step.Noop' + Preconditions = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + ) + OnPreconditionFalse = 'Continue' + PreconditionEvent = @{ + Type = 'ManualActionRequired' + Message = 'Operator action needed' + Data = @{ Ticket = 'INC-1234' } + } + } + + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'GuardedStep' + + $result.Preconditions.Count | Should -Be 1 + $result.OnPreconditionFalse | Should -Be 'Continue' + $result.PreconditionEvent.Type | Should -Be 'ManualActionRequired' + $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' + + # Verify deep-copy behavior. + $step.PreconditionEvent.Data.Ticket = 'CHANGED' + $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' + } + + It 'throws when OnPreconditionFalse has an invalid value' { + $step = @{ + Name = 'InvalidPolicy' + Type = 'IdLE.Step.Noop' + OnPreconditionFalse = 'StopAll' + } + + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'InvalidPolicy' } | Should -Throw + } + } +} diff --git a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 new file mode 100644 index 00000000..5308404f --- /dev/null +++ b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 @@ -0,0 +1,87 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Workflow schema validation - Condition/Precondition DSL parity' { + InModuleScope 'IdLE.Core' { + It 'rejects invalid Condition DSL nodes at definition validation time' { + $workflow = @{ + Name = 'Condition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'InvalidCondition' + Type = 'IdLE.Step.Noop' + Condition = @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors | Where-Object { $_ -like "*Steps[0].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + } + + + It 'rejects invalid Condition DSL nodes in OnFailureSteps at definition validation time' { + $workflow = @{ + Name = 'Condition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Primary'; Type = 'IdLE.Step.Noop' } + ) + OnFailureSteps = @( + @{ + Name = 'InvalidOnFailureCondition' + Type = 'IdLE.Step.Noop' + Condition = @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors | Where-Object { $_ -like "*OnFailureSteps[0].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + } + It 'rejects invalid Preconditions DSL nodes at definition validation time' { + $workflow = @{ + Name = 'Precondition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'InvalidPrecondition' + Type = 'IdLE.Step.Noop' + Preconditions = @( + @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + ) + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors | Where-Object { $_ -like "*Steps[0].Preconditions[0]*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + } + + It 'accepts valid preconditions using the same condition DSL' { + $workflow = @{ + Name = 'Precondition Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ValidPrecondition' + Type = 'IdLE.Step.Noop' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + Preconditions = @( + @{ Exists = @{ Path = 'Request.IdentityKeys.EmployeeId' } } + @{ In = @{ Path = 'Plan.LifecycleEvent'; Values = @('Joiner', 'Mover') } } + ) + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + $errors.Count | Should -Be 0 + } + } +} From f2795ecd907971ac702ae7d5e94b4f5ae797ef37 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:51:42 +0100 Subject: [PATCH 02/13] Support singular Precondition alias with shared DSL validation --- docs/use/workflows/preconditions.md | 2 + ...o-IdleWorkflowStepPreconditionSettings.ps1 | 88 ++++++++++++++----- .../Private/Test-IdleWorkflowSchema.ps1 | 22 ++++- ...WorkflowStepPreconditionSettings.Tests.ps1 | 26 ++++++ tests/Core/Test-IdleWorkflowSchema.Tests.ps1 | 37 ++++++++ 5 files changed, 151 insertions(+), 24 deletions(-) diff --git a/docs/use/workflows/preconditions.md b/docs/use/workflows/preconditions.md index 6f19dfa3..15693674 100644 --- a/docs/use/workflows/preconditions.md +++ b/docs/use/workflows/preconditions.md @@ -43,6 +43,8 @@ Add these optional properties to a workflow step definition: | `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default), `Fail`, or `Continue`. | | `PreconditionEvent` | `Hashtable` | No | Structured event emitted when a precondition fails. | +`Precondition` (singular) is accepted as a deprecated alias for one condition node. Do not define both `Precondition` and `Preconditions` on the same step; use `Preconditions` for new workflows. + ### PreconditionEvent schema | Key | Type | Required | Description | diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 index 21375f3d..c4cd5b6c 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -20,33 +20,77 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { # Runtime Preconditions: evaluated at execution time (not planning time). # Each precondition uses the same declarative condition DSL as Condition. - if (Test-IdleWorkflowStepKey -Step $Step -Key 'Preconditions') { - $rawPreconditions = Get-IdlePropertyValue -Object $Step -Name 'Preconditions' - if ($null -ne $rawPreconditions) { - $pcList = @($rawPreconditions) - for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { - $pc = $pcList[$pcIdx] - if ($pc -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $StepName, $pcIdx), - 'Workflow' - ) - } + $hasPreconditions = Test-IdleWorkflowStepKey -Step $Step -Key 'Preconditions' + $hasPrecondition = Test-IdleWorkflowStepKey -Step $Step -Key 'Precondition' - $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$pc) -StepName $StepName - if (@($pcErrors).Count -gt 0) { - throw [System.ArgumentException]::new( - ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $StepName, ([string]::Join(' ', @($pcErrors)))), - 'Workflow' - ) - } + $rawPreconditions = if ($hasPreconditions) { + Get-IdlePropertyValue -Object $Step -Name 'Preconditions' + } + else { + $null + } + + $rawPrecondition = if ($hasPrecondition) { + Get-IdlePropertyValue -Object $Step -Name 'Precondition' + } + else { + $null + } + + $hasPreconditionsValue = $null -ne $rawPreconditions + $hasPreconditionValue = $null -ne $rawPrecondition + + if ($hasPreconditionsValue -and $hasPreconditionValue) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' must not define both 'Preconditions' and deprecated alias 'Precondition'. Use only 'Preconditions'." -f $StepName), + 'Workflow' + ) + } + + if ($hasPreconditionsValue) { + $pcList = @($rawPreconditions) + for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { + $pc = $pcList[$pcIdx] + if ($pc -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $StepName, $pcIdx), + 'Workflow' + ) } - $normalized.Preconditions = @() - foreach ($pc in $pcList) { - $normalized.Preconditions += Copy-IdleDataObject -Value $pc + $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$pc) -StepName $StepName + if (@($pcErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $StepName, ([string]::Join(' ', @($pcErrors)))), + 'Workflow' + ) } } + + $normalized.Preconditions = @() + foreach ($pc in $pcList) { + $normalized.Preconditions += Copy-IdleDataObject -Value $pc + } + } + elseif ($hasPreconditionValue) { + if ($rawPrecondition -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': Precondition must be a hashtable (condition node). Use 'Preconditions' for the canonical array form." -f $StepName), + 'Workflow' + ) + } + + $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$rawPrecondition) -StepName $StepName + if (@($pcErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Precondition on step '{0}': {1}" -f $StepName, ([string]::Join(' ', @($pcErrors)))), + 'Workflow' + ) + } + + $normalized.Preconditions = @( + Copy-IdleDataObject -Value $rawPrecondition + ) } if (Test-IdleWorkflowStepKey -Step $Step -Key 'OnPreconditionFalse') { diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 5f6f8ebd..3968a2fd 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -23,7 +23,7 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Preconditions', 'OnPreconditionFalse', 'PreconditionEvent') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Preconditions', 'Precondition', 'OnPreconditionFalse', 'PreconditionEvent') foreach ($k in $Step.Keys) { if ($allowedStepKeys -notcontains $k) { $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -72,7 +72,14 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - if ($Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions) { + $hasPreconditions = $Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions + $hasPrecondition = $Step.ContainsKey('Precondition') -and $null -ne $Step.Precondition + + if ($hasPreconditions -and $hasPrecondition) { + $ErrorList.Add("'$StepPath' must not define both 'Preconditions' and deprecated alias 'Precondition'. Use only 'Preconditions'.") + } + + if ($hasPreconditions) { if ($Step.Preconditions -is [string] -or $Step.Preconditions -is [System.Collections.IDictionary] -or -not ($Step.Preconditions -is [System.Collections.IEnumerable])) { @@ -96,6 +103,17 @@ function Test-IdleWorkflowSchema { } } + if ($hasPrecondition) { + if ($Step.Precondition -isnot [System.Collections.IDictionary]) { + $ErrorList.Add("'$StepPath.Precondition' must be a hashtable (condition node). Use 'Preconditions' for the canonical array form.") + } + else { + foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Precondition) -StepName $StepPath)) { + $ErrorList.Add("'$StepPath.Precondition' has invalid condition schema: $schemaError") + } + } + } + if ($Step.ContainsKey('OnPreconditionFalse') -and $null -ne $Step.OnPreconditionFalse) { $opf = [string]$Step.OnPreconditionFalse if ($opf -notin @('Blocked', 'Fail', 'Continue')) { diff --git a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 index 9b0a10d7..c2916039 100644 --- a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 +++ b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 @@ -44,6 +44,32 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' } + + It 'normalizes deprecated singular Precondition alias to Preconditions array' { + $step = @{ + Name = 'SingularAlias' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } + } + + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'SingularAlias' + + $result.Preconditions.Count | Should -Be 1 + $result.Preconditions[0].Exists | Should -Be 'Request.IdentityKeys.EmployeeId' + } + + It 'throws when both Preconditions and Precondition are defined' { + $step = @{ + Name = 'ConflictingKeys' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } + Preconditions = @( + @{ Exists = 'Request.IdentityKeys.EmployeeId' } + ) + } + + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'ConflictingKeys' } | Should -Throw + } It 'throws when OnPreconditionFalse has an invalid value' { $step = @{ Name = 'InvalidPolicy' diff --git a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 index 5308404f..cca4d325 100644 --- a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 +++ b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 @@ -63,6 +63,43 @@ Describe 'Workflow schema validation - Condition/Precondition DSL parity' { @($errors | Where-Object { $_ -like "*Steps[0].Preconditions[0]*invalid condition schema*" }).Count | Should -BeGreaterThan 0 } + + It 'accepts deprecated singular Precondition alias with the same condition DSL' { + $workflow = @{ + Name = 'Singular Precondition Alias' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'SingularAlias' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + $errors.Count | Should -Be 0 + } + + It 'rejects defining both Preconditions and Precondition on the same step' { + $workflow = @{ + Name = 'Conflicting Precondition Keys' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ConflictingKeys' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } + Preconditions = @( + @{ Exists = 'Request.IdentityKeys.EmployeeId' } + ) + } + ) + } + + $errors = Test-IdleWorkflowSchema -Workflow $workflow + @($errors | Where-Object { $_ -like "*must not define both 'Preconditions' and deprecated alias 'Precondition'*" }).Count | Should -BeGreaterThan 0 + } It 'accepts valid preconditions using the same condition DSL' { $workflow = @{ Name = 'Precondition Validation' From fb3f363423a1423db03bd5bf994500bcb197aa11 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:10:28 +0100 Subject: [PATCH 03/13] Fail fast on unresolved condition and precondition paths --- docs/use/workflows/preconditions.md | 2 + .../Assert-IdleConditionPathsResolvable.ps1 | 123 ++++++++++++++++++ ...o-IdleWorkflowStepPreconditionSettings.ps1 | 10 +- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 4 +- src/IdLE.Core/Private/Test-IdlePathExists.ps1 | 39 ++++++ ...ert-IdleConditionPathsResolvable.Tests.ps1 | 46 +++++++ ...WorkflowStepPreconditionSettings.Tests.ps1 | 27 +++- 7 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 create mode 100644 src/IdLE.Core/Private/Test-IdlePathExists.ps1 create mode 100644 tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 diff --git a/docs/use/workflows/preconditions.md b/docs/use/workflows/preconditions.md index 15693674..809e4e28 100644 --- a/docs/use/workflows/preconditions.md +++ b/docs/use/workflows/preconditions.md @@ -124,6 +124,8 @@ Paths are resolved against the **execution-time context**, which includes: A leading `context.` prefix is ignored for readability (e.g. `context.Request.Intent.Department` resolves identically to `Request.Intent.Department`). +At planning time, IdLE validates that every `Path` referenced by `Condition`/`Preconditions` is resolvable in the current planning context. This enables fail-fast detection for typos or wrong roots (for example `Request.Context.OffboardingDate` vs `Request.Intent.OffboardingDate`). + --- ## Blocked vs. Failed vs. Continue outcomes diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 new file mode 100644 index 00000000..67514e13 --- /dev/null +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -0,0 +1,123 @@ +Set-StrictMode -Version Latest + +function Assert-IdleConditionPathsResolvable { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Condition, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $StepName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Source + ) + + function Add-IdlePathIfPresent { + param( + [Parameter(Mandatory)] + [System.Collections.Generic.List[string]] $PathList, + + [Parameter(Mandatory)] + [AllowNull()] + [object] $PathCandidate + ) + + if ($null -eq $PathCandidate) { + return + } + + $pathText = [string]$PathCandidate + if ([string]::IsNullOrWhiteSpace($pathText)) { + return + } + + if ($pathText.StartsWith('context.')) { + $pathText = $pathText.Substring(8) + } + + $null = $PathList.Add($pathText) + } + + function Get-IdleConditionPaths { + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary] $Node, + + [Parameter(Mandatory)] + [System.Collections.Generic.List[string]] $PathList + ) + + if ($Node.Contains('All')) { + foreach ($child in @($Node.All)) { + if ($child -is [System.Collections.IDictionary]) { + Get-IdleConditionPaths -Node $child -PathList $PathList + } + } + return + } + + if ($Node.Contains('Any')) { + foreach ($child in @($Node.Any)) { + if ($child -is [System.Collections.IDictionary]) { + Get-IdleConditionPaths -Node $child -PathList $PathList + } + } + return + } + + if ($Node.Contains('None')) { + foreach ($child in @($Node.None)) { + if ($child -is [System.Collections.IDictionary]) { + Get-IdleConditionPaths -Node $child -PathList $PathList + } + } + return + } + + if ($Node.Contains('Equals')) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.Equals.Path + return + } + + if ($Node.Contains('NotEquals')) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.NotEquals.Path + return + } + + 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 + } + return + } + + if ($Node.Contains('In')) { + Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.In.Path + return + } + } + + $paths = [System.Collections.Generic.List[string]]::new() + Get-IdleConditionPaths -Node $Condition -PathList $paths + + foreach ($path in @($paths | Select-Object -Unique)) { + if (-not (Test-IdlePathExists -Object $Context -Path $path)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' references path '{1}' in {2}, but the path does not exist in the current planning context. Check Request/Plan structure or ContextResolvers outputs." -f $StepName, $path, $Source), + 'Workflow' + ) + } + } +} diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 index c4cd5b6c..6c48897b 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -9,7 +9,11 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $StepName + [string] $StepName, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $PlanningContext ) $normalized = @{ @@ -65,6 +69,8 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { 'Workflow' ) } + + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$pc) -Context $PlanningContext -StepName $StepName -Source ("Preconditions[{0}]" -f $pcIdx) } $normalized.Preconditions = @() @@ -88,6 +94,8 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { ) } + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' + $normalized.Preconditions = @( Copy-IdleDataObject -Value $rawPrecondition ) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 2306e568..14415571 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -80,6 +80,8 @@ function ConvertTo-IdleWorkflowSteps { ) } + Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' + $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext if (-not $isApplicable) { $status = 'NotApplicable' @@ -126,7 +128,7 @@ function ConvertTo-IdleWorkflowSteps { $null } - $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName + $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName -PlanningContext $PlanningContext $preconditions = $preconditionSettings.Preconditions $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse $preconditionEvent = $preconditionSettings.PreconditionEvent diff --git a/src/IdLE.Core/Private/Test-IdlePathExists.ps1 b/src/IdLE.Core/Private/Test-IdlePathExists.ps1 new file mode 100644 index 00000000..4807573a --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdlePathExists.ps1 @@ -0,0 +1,39 @@ +Set-StrictMode -Version Latest + +function Test-IdlePathExists { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + $current = $Object + foreach ($segment in ($Path -split '\.')) { + if ($null -eq $current) { + return $false + } + + if ($current -is [System.Collections.IDictionary]) { + if (-not $current.Contains($segment)) { + return $false + } + + $current = $current[$segment] + continue + } + + $prop = $current.PSObject.Properties[$segment] + if ($null -eq $prop) { + return $false + } + + $current = $prop.Value + } + + return $true +} diff --git a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 new file mode 100644 index 00000000..206a9e10 --- /dev/null +++ b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 @@ -0,0 +1,46 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Assert-IdleConditionPathsResolvable' { + InModuleScope 'IdLE.Core' { + It 'accepts condition paths that exist in planning context' { + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ Exists = 'Request.Intent.OffboardingDate' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'CheckPaths' -Source 'Condition' + } | Should -Not -Throw + } + + It 'throws when at least one condition path does not exist in planning context' { + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ Exists = 'Request.Context.OffboardingDate' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'CheckPaths' -Source 'Condition' + } | Should -Throw + } + } +} diff --git a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 index c2916039..1b037536 100644 --- a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 +++ b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 @@ -10,7 +10,8 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { It 'returns null values when the step does not define precondition settings' { $step = @{ Name = 'Noop'; Type = 'IdLE.Step.Noop' } - $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'Noop' + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'Noop' -PlanningContext $planningContext $result.Preconditions | Should -BeNullOrEmpty $result.OnPreconditionFalse | Should -BeNullOrEmpty @@ -32,7 +33,8 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { } } - $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'GuardedStep' + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'GuardedStep' -PlanningContext $planningContext $result.Preconditions.Count | Should -Be 1 $result.OnPreconditionFalse | Should -Be 'Continue' @@ -52,7 +54,8 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } } - $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'SingularAlias' + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{ EmployeeId = 'E123' }; Intent = @{}; Context = @{} } } + $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'SingularAlias' -PlanningContext $planningContext $result.Preconditions.Count | Should -Be 1 $result.Preconditions[0].Exists | Should -Be 'Request.IdentityKeys.EmployeeId' @@ -68,8 +71,21 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { ) } - { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'ConflictingKeys' } | Should -Throw + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'ConflictingKeys' -PlanningContext $planningContext } | Should -Throw } + + It 'throws when precondition path does not exist in planning context' { + $step = @{ + Name = 'MissingPath' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.Context.OffboardingDate' } + } + + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'MissingPath' -PlanningContext $planningContext } | Should -Throw + } + It 'throws when OnPreconditionFalse has an invalid value' { $step = @{ Name = 'InvalidPolicy' @@ -77,7 +93,8 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { OnPreconditionFalse = 'StopAll' } - { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'InvalidPolicy' } | Should -Throw + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'InvalidPolicy' -PlanningContext $planningContext } | Should -Throw } } } From 587e643b6db6e202283df2c4c090ff5dcbd6e0a6 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:26:38 +0100 Subject: [PATCH 04/13] Improve condition path validation errors and deduplicate output --- .../Assert-IdleConditionPathsResolvable.ps1 | 23 +++++++++++++++---- ...ert-IdleConditionPathsResolvable.Tests.ps1 | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 67514e13..239f3ec1 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -23,6 +23,7 @@ function Assert-IdleConditionPathsResolvable { function Add-IdlePathIfPresent { param( [Parameter(Mandatory)] + [AllowEmptyCollection()] [System.Collections.Generic.List[string]] $PathList, [Parameter(Mandatory)] @@ -52,6 +53,7 @@ function Assert-IdleConditionPathsResolvable { [System.Collections.IDictionary] $Node, [Parameter(Mandatory)] + [AllowEmptyCollection()] [System.Collections.Generic.List[string]] $PathList ) @@ -112,12 +114,23 @@ function Assert-IdleConditionPathsResolvable { $paths = [System.Collections.Generic.List[string]]::new() Get-IdleConditionPaths -Node $Condition -PathList $paths - foreach ($path in @($paths | Select-Object -Unique)) { + $uniquePaths = @($paths | Select-Object -Unique) + if ($uniquePaths.Count -eq 0) { + return + } + + $missingPaths = @() + foreach ($path in $uniquePaths) { if (-not (Test-IdlePathExists -Object $Context -Path $path)) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' references path '{1}' in {2}, but the path does not exist in the current planning context. Check Request/Plan structure or ContextResolvers outputs." -f $StepName, $path, $Source), - 'Workflow' - ) + $missingPaths += $path } } + + if ($missingPaths.Count -gt 0) { + $missingPathList = [string]::Join(', ', $missingPaths) + throw [System.ArgumentException]::new( + ("Workflow step '{0}' has unresolved condition path(s) in {1}: [{2}]. Check Request/Plan structure or ContextResolvers outputs." -f $StepName, $Source, $missingPathList), + 'Workflow' + ) + } } diff --git a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 index 206a9e10..1ce9c21b 100644 --- a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 +++ b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 @@ -42,5 +42,23 @@ Describe 'Assert-IdleConditionPathsResolvable' { Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'CheckPaths' -Source 'Condition' } | Should -Throw } + + It 'reports unresolved paths in one clear error message with step/source context' { + $condition = @{ + All = @( + @{ Exists = 'Request.Context.OffboardingDate' } + @{ Exists = 'Request.Context.Manager' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'RegionCheck' -Source 'Preconditions[0]' + } | Should -Throw "*Workflow step 'RegionCheck' has unresolved condition path(s) in Preconditions[0]:*Request.Context.OffboardingDate*Request.Context.Manager*" + } } } From 81ee55915090b03cdca3ce28f4fa64a8ea713b99 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:27:20 +0100 Subject: [PATCH 05/13] Standardize workflow runtime precondition key to singular Precondition --- docs/use/workflows.md | 2 +- docs/use/workflows/preconditions.md | 16 ++-- ...o-IdleWorkflowStepPreconditionSettings.ps1 | 78 +++---------------- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 4 +- .../Private/Test-IdleWorkflowSchema.ps1 | 43 ++-------- .../Public/Invoke-IdlePlanObject.ps1 | 42 ++++------ ...ert-IdleConditionPathsResolvable.Tests.ps1 | 4 +- ...WorkflowStepPreconditionSettings.Tests.ps1 | 37 +-------- .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 4 +- tests/Core/Test-IdleWorkflowSchema.Tests.ps1 | 67 ++++------------ .../preconditions/blocked-default.psd1 | 6 +- .../preconditions/blocked-no-onfailure.psd1 | 6 +- .../workflows/preconditions/blocked.psd1 | 6 +- .../preconditions/continue-event.psd1 | 6 +- .../workflows/preconditions/continue.psd1 | 6 +- .../workflows/preconditions/event.psd1 | 6 +- .../preconditions/fail-runs-onfailure.psd1 | 6 +- .../workflows/preconditions/fail.psd1 | 6 +- .../preconditions/invalid-event-type.psd1 | 6 +- .../invalid-onpreconditionfalse.psd1 | 6 +- .../preconditions/invalid-schema.psd1 | 6 +- .../invalid-single-hashtable.psd1 | 2 +- .../onfailure-blocked-precondition.psd1 | 12 ++- .../onfailure-continue-precondition.psd1 | 12 ++- .../workflows/preconditions/passing.psd1 | 6 +- 25 files changed, 133 insertions(+), 262 deletions(-) diff --git a/docs/use/workflows.md b/docs/use/workflows.md index 1031ff5e..b5b4b108 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -31,7 +31,7 @@ Each step supports several optional execution control properties: | Property | Evaluated at | Purpose | |---|---|---| | `Condition` | Plan time | Include or skip the step based on request/intent data. | -| `Preconditions` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](workflows/preconditions.md). | +| `Precondition` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](workflows/preconditions.md). | | `OnFailureSteps` | After failure (workflow-level) | Cleanup/rollback steps run after a primary step fails. | --- diff --git a/docs/use/workflows/preconditions.md b/docs/use/workflows/preconditions.md index 809e4e28..9f893b1c 100644 --- a/docs/use/workflows/preconditions.md +++ b/docs/use/workflows/preconditions.md @@ -39,12 +39,10 @@ Add these optional properties to a workflow step definition: | Property | Type | Required | Description | |---|---|---|---| -| `Preconditions` | `Array[Condition]` | No | One or more condition nodes (same DSL as `Condition`). All must pass for the step to execute. | +| `Precondition` | `Condition` | No | One condition node (same DSL as `Condition`). It must evaluate to true for the step to execute. | | `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default), `Fail`, or `Continue`. | | `PreconditionEvent` | `Hashtable` | No | Structured event emitted when a precondition fails. | -`Precondition` (singular) is accepted as a deprecated alias for one condition node. Do not define both `Precondition` and `Preconditions` on the same step; use `Preconditions` for new workflows. - ### PreconditionEvent schema | Key | Type | Required | Description | @@ -74,14 +72,16 @@ Add these optional properties to a workflow step definition: # Runtime guard: only execute if BYOD wipe is confirmed. # Note: the condition DSL compares values as strings. # Request.Context.Byod.WipeConfirmed must be the string 'true' (e.g. set by a ContextResolver). - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Request.Context.Byod.WipeConfirmed' Value = 'true' } } - ) + ) + } OnPreconditionFalse = 'Blocked' PreconditionEvent = @{ Type = 'ManualActionRequired' @@ -99,7 +99,7 @@ Add these optional properties to a workflow step definition: ## Condition DSL -Each entry in `Preconditions` uses the same **declarative condition DSL** as the `Condition` +`Precondition` uses the same **declarative condition DSL** as the `Condition` property. Supported operators: | Operator | Shape | Description | @@ -124,7 +124,7 @@ Paths are resolved against the **execution-time context**, which includes: A leading `context.` prefix is ignored for readability (e.g. `context.Request.Intent.Department` resolves identically to `Request.Intent.Department`). -At planning time, IdLE validates that every `Path` referenced by `Condition`/`Preconditions` is resolvable in the current planning context. This enables fail-fast detection for typos or wrong roots (for example `Request.Context.OffboardingDate` vs `Request.Intent.OffboardingDate`). +At planning time, IdLE validates that every `Path` referenced by `Condition`/`Precondition` is resolvable in the current planning context. This enables fail-fast detection for typos or wrong roots (for example `Request.Context.OffboardingDate` vs `Request.Intent.OffboardingDate`). --- @@ -218,7 +218,7 @@ Ensure context values are stored as strings when using `Equals` or `In` operator ## Backward compatibility -Steps without `Preconditions` behave exactly as before. Adding preconditions to a step does not +Steps without `Precondition` behave exactly as before. Adding a precondition to a step does not affect any other steps. --- diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 index 6c48897b..7ed561a8 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -17,88 +17,34 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { ) $normalized = @{ - Preconditions = $null + Precondition = $null OnPreconditionFalse = $null PreconditionEvent = $null } - # Runtime Preconditions: evaluated at execution time (not planning time). - # Each precondition uses the same declarative condition DSL as Condition. - $hasPreconditions = Test-IdleWorkflowStepKey -Step $Step -Key 'Preconditions' - $hasPrecondition = Test-IdleWorkflowStepKey -Step $Step -Key 'Precondition' - - $rawPreconditions = if ($hasPreconditions) { - Get-IdlePropertyValue -Object $Step -Name 'Preconditions' - } - else { - $null - } - - $rawPrecondition = if ($hasPrecondition) { - Get-IdlePropertyValue -Object $Step -Name 'Precondition' - } - else { - $null - } - - $hasPreconditionsValue = $null -ne $rawPreconditions - $hasPreconditionValue = $null -ne $rawPrecondition - - if ($hasPreconditionsValue -and $hasPreconditionValue) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' must not define both 'Preconditions' and deprecated alias 'Precondition'. Use only 'Preconditions'." -f $StepName), - 'Workflow' - ) - } - - if ($hasPreconditionsValue) { - $pcList = @($rawPreconditions) - for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { - $pc = $pcList[$pcIdx] - if ($pc -isnot [System.Collections.IDictionary]) { + # Runtime Precondition: evaluated at execution time (not planning time). + # Uses the same declarative condition DSL as Condition. + if (Test-IdleWorkflowStepKey -Step $Step -Key 'Precondition') { + $rawPrecondition = Get-IdlePropertyValue -Object $Step -Name 'Precondition' + if ($null -ne $rawPrecondition) { + if ($rawPrecondition -isnot [System.Collections.IDictionary]) { throw [System.ArgumentException]::new( - ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $StepName, $pcIdx), + ("Workflow step '{0}': Precondition must be a hashtable (condition node)." -f $StepName), 'Workflow' ) } - $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$pc) -StepName $StepName + $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$rawPrecondition) -StepName $StepName if (@($pcErrors).Count -gt 0) { throw [System.ArgumentException]::new( - ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $StepName, ([string]::Join(' ', @($pcErrors)))), + ("Invalid Precondition on step '{0}': {1}" -f $StepName, ([string]::Join(' ', @($pcErrors)))), 'Workflow' ) } - Assert-IdleConditionPathsResolvable -Condition ([hashtable]$pc) -Context $PlanningContext -StepName $StepName -Source ("Preconditions[{0}]" -f $pcIdx) - } - - $normalized.Preconditions = @() - foreach ($pc in $pcList) { - $normalized.Preconditions += Copy-IdleDataObject -Value $pc - } - } - elseif ($hasPreconditionValue) { - if ($rawPrecondition -isnot [System.Collections.IDictionary]) { - throw [System.ArgumentException]::new( - ("Workflow step '{0}': Precondition must be a hashtable (condition node). Use 'Preconditions' for the canonical array form." -f $StepName), - 'Workflow' - ) + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' + $normalized.Precondition = Copy-IdleDataObject -Value $rawPrecondition } - - $pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$rawPrecondition) -StepName $StepName - if (@($pcErrors).Count -gt 0) { - throw [System.ArgumentException]::new( - ("Invalid Precondition on step '{0}': {1}" -f $StepName, ([string]::Join(' ', @($pcErrors)))), - 'Workflow' - ) - } - - Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' - - $normalized.Preconditions = @( - Copy-IdleDataObject -Value $rawPrecondition - ) } if (Test-IdleWorkflowStepKey -Step $Step -Key 'OnPreconditionFalse') { diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 14415571..64779e4c 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -129,7 +129,7 @@ function ConvertTo-IdleWorkflowSteps { } $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName -PlanningContext $PlanningContext - $preconditions = $preconditionSettings.Preconditions + $precondition = $preconditionSettings.Precondition $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse $preconditionEvent = $preconditionSettings.PreconditionEvent @@ -139,7 +139,7 @@ function ConvertTo-IdleWorkflowSteps { Type = $stepType Description = $description Condition = Copy-IdleDataObject -Value $condition - Preconditions = $preconditions + Precondition = $precondition OnPreconditionFalse = $onPreconditionFalse PreconditionEvent = $preconditionEvent With = $with diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 3968a2fd..1069858f 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -23,7 +23,7 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Preconditions', 'Precondition', 'OnPreconditionFalse', 'PreconditionEvent') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Precondition', 'OnPreconditionFalse', 'PreconditionEvent') foreach ($k in $Step.Keys) { if ($allowedStepKeys -notcontains $k) { $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -57,8 +57,8 @@ function Test-IdleWorkflowSchema { } } - # Helper: Validate Preconditions, OnPreconditionFalse, and PreconditionEvent fields on a step. - function Test-IdleWorkflowStepPreconditions { + # Helper: Validate Precondition, OnPreconditionFalse, and PreconditionEvent fields on a step. + function Test-IdleWorkflowStepPreconditionSettings { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -72,40 +72,9 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - $hasPreconditions = $Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions - $hasPrecondition = $Step.ContainsKey('Precondition') -and $null -ne $Step.Precondition - - if ($hasPreconditions -and $hasPrecondition) { - $ErrorList.Add("'$StepPath' must not define both 'Preconditions' and deprecated alias 'Precondition'. Use only 'Preconditions'.") - } - - if ($hasPreconditions) { - if ($Step.Preconditions -is [string] -or - $Step.Preconditions -is [System.Collections.IDictionary] -or - -not ($Step.Preconditions -is [System.Collections.IEnumerable])) { - $ErrorList.Add("'$StepPath.Preconditions' must be an array/list of condition hashtables (not a single hashtable).") - } - else { - $pcIdx = 0 - foreach ($pc in @($Step.Preconditions)) { - if ($pc -isnot [System.Collections.IDictionary]) { - $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' must be a hashtable (condition node).") - $pcIdx++ - continue - } - - foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$pc) -StepName $StepPath)) { - $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' has invalid condition schema: $schemaError") - } - - $pcIdx++ - } - } - } - - if ($hasPrecondition) { + if ($Step.ContainsKey('Precondition') -and $null -ne $Step.Precondition) { if ($Step.Precondition -isnot [System.Collections.IDictionary]) { - $ErrorList.Add("'$StepPath.Precondition' must be a hashtable (condition node). Use 'Preconditions' for the canonical array form.") + $ErrorList.Add("'$StepPath.Precondition' must be a hashtable (condition node).") } else { foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Precondition) -StepName $StepPath)) { @@ -225,7 +194,7 @@ function Test-IdleWorkflowSchema { } Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $ErrorList - Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $ErrorList + Test-IdleWorkflowStepPreconditionSettings -Step $step -StepPath $stepPath -ErrorList $ErrorList $i++ } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index f060f765..f3484670 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -318,24 +318,20 @@ function Invoke-IdlePlanObject { continue } - # Runtime Preconditions: evaluated immediately before step execution (online, not planning-time). + # Runtime Precondition: evaluated immediately before step execution (online, not planning-time). # Blocked = policy/precondition gate (does not trigger OnFailureSteps). Stops execution. # Fail = treated as a technical failure (triggers OnFailureSteps). Stops execution. # Continue = emits events but skips the step and continues to the next step. # Non-IDictionary precondition nodes are treated as precondition failures (fail closed). - $stepPreconditions = Get-IdlePropertyValue -Object $step -Name 'Preconditions' - if ($null -ne $stepPreconditions -and @($stepPreconditions).Count -gt 0) { + $stepPrecondition = Get-IdlePropertyValue -Object $step -Name 'Precondition' + if ($null -ne $stepPrecondition) { $preconditionPassed = $true - foreach ($pc in @($stepPreconditions)) { - if ($pc -isnot [System.Collections.IDictionary]) { - # Fail closed: a malformed or unexpected node type is treated as a failed precondition. - $preconditionPassed = $false - break - } - if (-not (Test-IdleCondition -Condition ([hashtable]$pc) -Context $preconditionContext)) { - $preconditionPassed = $false - break - } + if ($stepPrecondition -isnot [System.Collections.IDictionary]) { + # 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 } if (-not $preconditionPassed) { @@ -621,21 +617,17 @@ function Invoke-IdlePlanObject { continue } - # Runtime Preconditions for OnFailure steps: evaluated immediately before execution. + # Runtime Precondition for OnFailure steps: evaluated immediately before execution. # OnFailure runs best-effort, so precondition failures skip the step but do not halt # remaining OnFailure steps. Non-IDictionary nodes are treated as failures (fail closed). - $ofPreconditions = Get-IdlePropertyValue -Object $ofStep -Name 'Preconditions' - if ($null -ne $ofPreconditions -and @($ofPreconditions).Count -gt 0) { + $ofPrecondition = Get-IdlePropertyValue -Object $ofStep -Name 'Precondition' + if ($null -ne $ofPrecondition) { $ofPreconditionPassed = $true - foreach ($opc in @($ofPreconditions)) { - if ($opc -isnot [System.Collections.IDictionary]) { - $ofPreconditionPassed = $false - break - } - if (-not (Test-IdleCondition -Condition ([hashtable]$opc) -Context $preconditionContext)) { - $ofPreconditionPassed = $false - break - } + if ($ofPrecondition -isnot [System.Collections.IDictionary]) { + $ofPreconditionPassed = $false + } + elseif (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) { + $ofPreconditionPassed = $false } if (-not $ofPreconditionPassed) { diff --git a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 index 1ce9c21b..d2119890 100644 --- a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 +++ b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 @@ -57,8 +57,8 @@ Describe 'Assert-IdleConditionPathsResolvable' { } { - Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'RegionCheck' -Source 'Preconditions[0]' - } | Should -Throw "*Workflow step 'RegionCheck' has unresolved condition path(s) in Preconditions[0]:*Request.Context.OffboardingDate*Request.Context.Manager*" + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'RegionCheck' -Source 'Precondition' + } | Should -Throw "*Workflow step 'RegionCheck' has unresolved condition path(s) in Precondition:*Request.Context.OffboardingDate*Request.Context.Manager*" } } } diff --git a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 index 1b037536..7e89bfba 100644 --- a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 +++ b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 @@ -13,7 +13,7 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'Noop' -PlanningContext $planningContext - $result.Preconditions | Should -BeNullOrEmpty + $result.Precondition | Should -BeNullOrEmpty $result.OnPreconditionFalse | Should -BeNullOrEmpty $result.PreconditionEvent | Should -BeNullOrEmpty } @@ -22,9 +22,7 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { $step = @{ Name = 'GuardedStep' Type = 'IdLE.Step.Noop' - Preconditions = @( - @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } - ) + Precondition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } OnPreconditionFalse = 'Continue' PreconditionEvent = @{ Type = 'ManualActionRequired' @@ -36,7 +34,7 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'GuardedStep' -PlanningContext $planningContext - $result.Preconditions.Count | Should -Be 1 + $result.Precondition.Equals.Path | Should -Be 'Plan.LifecycleEvent' $result.OnPreconditionFalse | Should -Be 'Continue' $result.PreconditionEvent.Type | Should -Be 'ManualActionRequired' $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' @@ -46,35 +44,6 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' } - - It 'normalizes deprecated singular Precondition alias to Preconditions array' { - $step = @{ - Name = 'SingularAlias' - Type = 'IdLE.Step.Noop' - Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } - } - - $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{ EmployeeId = 'E123' }; Intent = @{}; Context = @{} } } - $result = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'SingularAlias' -PlanningContext $planningContext - - $result.Preconditions.Count | Should -Be 1 - $result.Preconditions[0].Exists | Should -Be 'Request.IdentityKeys.EmployeeId' - } - - It 'throws when both Preconditions and Precondition are defined' { - $step = @{ - Name = 'ConflictingKeys' - Type = 'IdLE.Step.Noop' - Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } - Preconditions = @( - @{ Exists = 'Request.IdentityKeys.EmployeeId' } - ) - } - - $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } - { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'ConflictingKeys' -PlanningContext $planningContext } | Should -Throw - } - It 'throws when precondition path does not exist in planning context' { $step = @{ Name = 'MissingPath' diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 31b6e19b..8b8c19cb 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -320,12 +320,12 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw } - It 'throws when Preconditions is a single hashtable instead of an array' { + It 'accepts Precondition as a single condition object' { $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-single-hashtable.psd1' $req = New-IdleRequest -LifecycleEvent 'Joiner' $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPCSingleHt') } - { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Not -Throw } } diff --git a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 index cca4d325..1caf4eb2 100644 --- a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 +++ b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 @@ -24,7 +24,6 @@ Describe 'Workflow schema validation - Condition/Precondition DSL parity' { @($errors | Where-Object { $_ -like "*Steps[0].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 } - It 'rejects invalid Condition DSL nodes in OnFailureSteps at definition validation time' { $workflow = @{ Name = 'Condition Validation' @@ -44,75 +43,39 @@ Describe 'Workflow schema validation - Condition/Precondition DSL parity' { $errors = Test-IdleWorkflowSchema -Workflow $workflow @($errors | Where-Object { $_ -like "*OnFailureSteps[0].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 } - It 'rejects invalid Preconditions DSL nodes at definition validation time' { - $workflow = @{ - Name = 'Precondition Validation' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'InvalidPrecondition' - Type = 'IdLE.Step.Noop' - Preconditions = @( - @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } - ) - } - ) - } - - $errors = Test-IdleWorkflowSchema -Workflow $workflow - @($errors | Where-Object { $_ -like "*Steps[0].Preconditions[0]*invalid condition schema*" }).Count | Should -BeGreaterThan 0 - } - - It 'accepts deprecated singular Precondition alias with the same condition DSL' { + It 'rejects invalid Precondition DSL node at definition validation time' { $workflow = @{ - Name = 'Singular Precondition Alias' + Name = 'Precondition Validation' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'SingularAlias' + Name = 'InvalidPrecondition' Type = 'IdLE.Step.Noop' - Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } + Precondition = @{ Foo = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } } ) } $errors = Test-IdleWorkflowSchema -Workflow $workflow - $errors.Count | Should -Be 0 + @($errors | Where-Object { $_ -like "*Steps[0].Precondition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 } - It 'rejects defining both Preconditions and Precondition on the same step' { - $workflow = @{ - Name = 'Conflicting Precondition Keys' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'ConflictingKeys' - Type = 'IdLE.Step.Noop' - Precondition = @{ Exists = 'Request.IdentityKeys.EmployeeId' } - Preconditions = @( - @{ Exists = 'Request.IdentityKeys.EmployeeId' } - ) - } - ) - } - - $errors = Test-IdleWorkflowSchema -Workflow $workflow - @($errors | Where-Object { $_ -like "*must not define both 'Preconditions' and deprecated alias 'Precondition'*" }).Count | Should -BeGreaterThan 0 - } - It 'accepts valid preconditions using the same condition DSL' { + It 'accepts valid precondition using the same condition DSL' { $workflow = @{ Name = 'Precondition Validation' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'ValidPrecondition' - Type = 'IdLE.Step.Noop' - Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } - Preconditions = @( - @{ Exists = @{ Path = 'Request.IdentityKeys.EmployeeId' } } - @{ In = @{ Path = 'Plan.LifecycleEvent'; Values = @('Joiner', 'Mover') } } - ) + Name = 'ValidPrecondition' + Type = 'IdLE.Step.Noop' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + Precondition = @{ + All = @( + @{ Exists = @{ Path = 'Request.IdentityKeys.EmployeeId' } } + @{ In = @{ Path = 'Plan.LifecycleEvent'; Values = @('Joiner', 'Mover') } } + ) + } } ) } diff --git a/tests/fixtures/workflows/preconditions/blocked-default.psd1 b/tests/fixtures/workflows/preconditions/blocked-default.psd1 index a216facd..0bf4cdb1 100644 --- a/tests/fixtures/workflows/preconditions/blocked-default.psd1 +++ b/tests/fixtures/workflows/preconditions/blocked-default.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.BlockedDefault' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } } ) } diff --git a/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 index ebfccaf4..e0ef8ab7 100644 --- a/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 +++ b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.BlockedNoOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } } ) OnFailureSteps = @( diff --git a/tests/fixtures/workflows/preconditions/blocked.psd1 b/tests/fixtures/workflows/preconditions/blocked.psd1 index 8d51859a..2faa473b 100644 --- a/tests/fixtures/workflows/preconditions/blocked.psd1 +++ b/tests/fixtures/workflows/preconditions/blocked.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.BlockedPrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Blocked' } @{ diff --git a/tests/fixtures/workflows/preconditions/continue-event.psd1 b/tests/fixtures/workflows/preconditions/continue-event.psd1 index a3f5ea0d..5f10bcc7 100644 --- a/tests/fixtures/workflows/preconditions/continue-event.psd1 +++ b/tests/fixtures/workflows/preconditions/continue-event.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.ContinuePreconditionEvent' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Continue' PreconditionEvent = @{ Type = 'PolicyAdvisory' diff --git a/tests/fixtures/workflows/preconditions/continue.psd1 b/tests/fixtures/workflows/preconditions/continue.psd1 index 0d56086c..960f5f1b 100644 --- a/tests/fixtures/workflows/preconditions/continue.psd1 +++ b/tests/fixtures/workflows/preconditions/continue.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.ContinuePrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Continue' } @{ diff --git a/tests/fixtures/workflows/preconditions/event.psd1 b/tests/fixtures/workflows/preconditions/event.psd1 index 3e6cacf1..1c4956a3 100644 --- a/tests/fixtures/workflows/preconditions/event.psd1 +++ b/tests/fixtures/workflows/preconditions/event.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.PreconditionEvent' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } PreconditionEvent = @{ Type = 'ManualActionRequired' Message = 'Perform Intune wipe before proceeding' diff --git a/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 index ada7f0be..c25b05a0 100644 --- a/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 +++ b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailRunsOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } ) diff --git a/tests/fixtures/workflows/preconditions/fail.psd1 b/tests/fixtures/workflows/preconditions/fail.psd1 index 1a572cf7..3f10d445 100644 --- a/tests/fixtures/workflows/preconditions/fail.psd1 +++ b/tests/fixtures/workflows/preconditions/fail.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailPrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } @{ diff --git a/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 index 8de5d90a..c1a640cb 100644 --- a/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidPCEvt' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } PreconditionEvent = @{ Message = 'Some message' } diff --git a/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 index 166161e8..55206a77 100644 --- a/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidOPF' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Skip' } ) diff --git a/tests/fixtures/workflows/preconditions/invalid-schema.psd1 b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 index 3caa0e71..7206bf35 100644 --- a/tests/fixtures/workflows/preconditions/invalid-schema.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 @@ -5,11 +5,13 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidPreconditionSchema' - Preconditions = @( + Precondition = @{ + All = @( @{ UnknownKey = 'bad' } - ) + ) + } } ) } diff --git a/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 index 316f51b7..964b1642 100644 --- a/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 +++ b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 @@ -5,7 +5,7 @@ @{ Name = 'Step1' Type = 'IdLE.Step.InvalidPCSingleHt' - Preconditions = @{ + Precondition = @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' diff --git a/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 index 5fcc341c..cc339c16 100644 --- a/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 +++ b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailRunsOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } ) @@ -20,14 +22,16 @@ @{ Name = 'Cleanup' Type = 'IdLE.Step.OnFailureCleanup' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } } ) } diff --git a/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 index 9ac0b888..3062c8ef 100644 --- a/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 +++ b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.FailRunsOnFailure' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Fail' } ) @@ -20,14 +22,16 @@ @{ Name = 'Cleanup' Type = 'IdLE.Step.OnFailureCleanup' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Joiner' } } - ) + ) + } OnPreconditionFalse = 'Continue' } ) diff --git a/tests/fixtures/workflows/preconditions/passing.psd1 b/tests/fixtures/workflows/preconditions/passing.psd1 index 4374340b..dcb17e4a 100644 --- a/tests/fixtures/workflows/preconditions/passing.psd1 +++ b/tests/fixtures/workflows/preconditions/passing.psd1 @@ -5,14 +5,16 @@ @{ Name = 'Step1' Type = 'IdLE.Step.PassingPrecondition' - Preconditions = @( + Precondition = @{ + All = @( @{ Equals = @{ Path = 'Plan.LifecycleEvent' Value = 'Leaver' } } - ) + ) + } } ) } From e9008aeb513777621c4d79c79b96feb9ad764632 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:48:22 +0100 Subject: [PATCH 06/13] Make Request.Context precondition path checks soft at planning --- docs/use/workflows/preconditions.md | 2 +- .../Assert-IdleConditionPathsResolvable.ps1 | 8 +++++++- ...To-IdleWorkflowStepPreconditionSettings.ps1 | 2 +- ...sert-IdleConditionPathsResolvable.Tests.ps1 | 18 ++++++++++++++++++ ...eWorkflowStepPreconditionSettings.Tests.ps1 | 15 +++++++++++++-- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/use/workflows/preconditions.md b/docs/use/workflows/preconditions.md index 9f893b1c..c41b446a 100644 --- a/docs/use/workflows/preconditions.md +++ b/docs/use/workflows/preconditions.md @@ -124,7 +124,7 @@ Paths are resolved against the **execution-time context**, which includes: A leading `context.` prefix is ignored for readability (e.g. `context.Request.Intent.Department` resolves identically to `Request.Intent.Department`). -At planning time, IdLE validates that every `Path` referenced by `Condition`/`Precondition` is resolvable in the current planning context. This enables fail-fast detection for typos or wrong roots (for example `Request.Context.OffboardingDate` vs `Request.Intent.OffboardingDate`). +At planning time, IdLE validates `Path` references to fail fast on typos and wrong roots. For `Precondition`, unresolved paths under `Request.Context.*` are treated as soft (non-fatal) to support context enrichment that may arrive later at runtime (for example via host/runtime context resolver behavior). Other unresolved roots still fail fast. --- diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 239f3ec1..732fa54a 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -17,7 +17,10 @@ function Assert-IdleConditionPathsResolvable { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $Source + [string] $Source, + + [Parameter()] + [switch] $AllowMissingRequestContextPaths ) function Add-IdlePathIfPresent { @@ -122,6 +125,9 @@ function Assert-IdleConditionPathsResolvable { $missingPaths = @() foreach ($path in $uniquePaths) { if (-not (Test-IdlePathExists -Object $Context -Path $path)) { + if ($AllowMissingRequestContextPaths -and $path.StartsWith('Request.Context.')) { + continue + } $missingPaths += $path } } diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 index 7ed561a8..457ed3ea 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -42,7 +42,7 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { ) } - Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' -AllowMissingRequestContextPaths $normalized.Precondition = Copy-IdleDataObject -Value $rawPrecondition } } diff --git a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 index d2119890..96551e97 100644 --- a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 +++ b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 @@ -60,5 +60,23 @@ Describe 'Assert-IdleConditionPathsResolvable' { Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'RegionCheck' -Source 'Precondition' } | Should -Throw "*Workflow step 'RegionCheck' has unresolved condition path(s) in Precondition:*Request.Context.OffboardingDate*Request.Context.Manager*" } + + It 'can allow unresolved Request.Context paths when explicitly enabled' { + $condition = @{ + All = @( + @{ Exists = 'Request.Context.OffboardingDate' } + @{ Exists = 'Request.Context.Manager' } + ) + } + + $context = @{ + Plan = @{ LifecycleEvent = 'Joiner' } + Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + } + + { + Assert-IdleConditionPathsResolvable -Condition $condition -Context $context -StepName 'RegionCheck' -Source 'Precondition' -AllowMissingRequestContextPaths + } | Should -Not -Throw + } } } diff --git a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 index 7e89bfba..1b25097e 100644 --- a/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 +++ b/tests/Core/ConvertTo-IdleWorkflowStepPreconditionSettings.Tests.ps1 @@ -44,7 +44,7 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { $result.PreconditionEvent.Data.Ticket | Should -Be 'INC-1234' } - It 'throws when precondition path does not exist in planning context' { + It 'does not throw when precondition uses unresolved Request.Context path in planning context' { $step = @{ Name = 'MissingPath' Type = 'IdLE.Step.Noop' @@ -52,7 +52,18 @@ Describe 'ConvertTo-IdleWorkflowStepPreconditionSettings' { } $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } - { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'MissingPath' -PlanningContext $planningContext } | Should -Throw + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'MissingPath' -PlanningContext $planningContext } | Should -Not -Throw + } + + It 'throws when precondition uses unresolved non-context path in planning context' { + $step = @{ + Name = 'MissingIntentPath' + Type = 'IdLE.Step.Noop' + Precondition = @{ Exists = 'Request.Intent.OffboardingDate' } + } + + $planningContext = @{ Plan = @{ LifecycleEvent = 'Joiner' }; Request = @{ IdentityKeys = @{}; Intent = @{}; Context = @{} } } + { ConvertTo-IdleWorkflowStepPreconditionSettings -Step $step -StepName 'MissingIntentPath' -PlanningContext $planningContext } | Should -Throw } It 'throws when OnPreconditionFalse has an invalid value' { From 3455290831f7cf74a722aa146f5f9dde03270ff8 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:36:48 +0100 Subject: [PATCH 07/13] Emit soft precondition path warnings into plan and export --- docs/use/workflows/preconditions.md | 2 + .../Assert-IdleConditionPathsResolvable.ps1 | 27 ++++++++++++- .../ConvertTo-IdlePlanExportObject.ps1 | 19 +++++++++ ...o-IdleWorkflowStepPreconditionSettings.ps1 | 2 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 2 +- tests/Core/Export-IdlePlan.Tests.ps1 | 40 +++++++++++++++++++ .../plan-export/expected/plan-export.json | 3 +- 7 files changed, 91 insertions(+), 4 deletions(-) diff --git a/docs/use/workflows/preconditions.md b/docs/use/workflows/preconditions.md index c41b446a..3d9932fe 100644 --- a/docs/use/workflows/preconditions.md +++ b/docs/use/workflows/preconditions.md @@ -125,6 +125,8 @@ A leading `context.` prefix is ignored for readability (e.g. `context.Request.In resolves identically to `Request.Intent.Department`). At planning time, IdLE validates `Path` references to fail fast on typos and wrong roots. For `Precondition`, unresolved paths under `Request.Context.*` are treated as soft (non-fatal) to support context enrichment that may arrive later at runtime (for example via host/runtime context resolver behavior). Other unresolved roots still fail fast. +When this soft-check path is used, IdLE records a planning warning (`PreconditionContextPathUnresolvedAtPlan`) in `Plan.Warnings`, and the warning is included in `Export-IdlePlan` output for CI policy checks. + --- diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 732fa54a..8b6e5f59 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -20,7 +20,11 @@ function Assert-IdleConditionPathsResolvable { [string] $Source, [Parameter()] - [switch] $AllowMissingRequestContextPaths + [switch] $AllowMissingRequestContextPaths, + + [Parameter()] + [AllowNull()] + [object] $WarningSink ) function Add-IdlePathIfPresent { @@ -123,15 +127,36 @@ function Assert-IdleConditionPathsResolvable { } $missingPaths = @() + $softMissingContextPaths = @() foreach ($path in $uniquePaths) { if (-not (Test-IdlePathExists -Object $Context -Path $path)) { if ($AllowMissingRequestContextPaths -and $path.StartsWith('Request.Context.')) { + $softMissingContextPaths += $path continue } $missingPaths += $path } } + 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 ($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 ($missingPaths.Count -gt 0) { $missingPathList = [string]::Join(', ', $missingPaths) throw [System.ArgumentException]::new( diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index dc83bd33..9c18cba7 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -273,10 +273,29 @@ function ConvertTo-IdlePlanExportObject { $stepList += $stepMap } + + # ---- Plan warnings ------------------------------------------------------ + $rawWarnings = Get-FirstPropertyValue -Object $Plan -Names @('Warnings', 'PlanningWarnings') + $warningList = @() + foreach ($w in @($rawWarnings)) { + if ($null -eq $w) { continue } + + $warningMap = New-OrderedMap + $warningMap.code = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Code', 'code')) + $warningMap.type = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Type', 'type')) + $warningMap.step = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Step', 'step', 'StepName')) + $warningMap.source = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Source', 'source')) + $warningMap.paths = Get-FirstPropertyValue -Object $w -Names @('Paths', 'paths') + $warningMap.message = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Message', 'message')) + + $warningList += $warningMap + } + $planMap = New-OrderedMap $planMap.id = $planId $planMap.mode = $mode $planMap.steps = $stepList + $planMap.warnings = $warningList # ---- Metadata block ------------------------------------------------------ $metadataMap = New-OrderedMap diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 index 457ed3ea..f03f8c31 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -42,7 +42,7 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { ) } - Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' -AllowMissingRequestContextPaths + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' -AllowMissingRequestContextPaths -WarningSink (Get-IdlePropertyValue -Object $PlanningContext.Plan -Name 'Warnings') $normalized.Precondition = Copy-IdleDataObject -Value $rawPrecondition } } diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 0c863142..43c473df 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -107,7 +107,7 @@ function New-IdlePlanObject { OnFailureSteps = @() Actions = @() - Warnings = @() + Warnings = [System.Collections.ArrayList]::new() Providers = $Providers } diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index b8482b14..7ce832d5 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -80,6 +80,46 @@ Describe 'Export-IdlePlan' { } } + + It 'includes planning warnings in exported plan for CI checks' { + $cid = '22222222-2222-2222-2222-222222222222' + + $wfPath = New-IdleTestWorkflowFile -FileName 'joiner-export-warning.psd1' -Content @' +@{ + Name = 'Joiner - Export Warning Fixture' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Check Context' + Type = 'EnsureMailbox' + Precondition = @{ Exists = 'Request.Context.OffboardingDate' } + With = @{ mailboxType = 'User' } + } + ) +} +'@ + + $req = New-IdleTestRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` + -Intent ([ordered]@{ department = 'IT' }) + + $providers = @{ + Dummy = $true + StepRegistry = @{ 'EnsureMailbox' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('EnsureMailbox') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + @($plan.Warnings).Count | Should -BeGreaterThan 0 + + $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' + } Context 'Contract invariants' { It 'always includes schemaVersion 1.0' { $cid = '11111111-1111-1111-1111-111111111111' diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index a21c7d09..adbf1042 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -35,7 +35,8 @@ }, "expectedState": null } - ] + ], + "warnings": [] }, "metadata": { "generatedBy": "Export-IdlePlanObject", From 01263613da3324a42704e213d8be4d81d7c7da1d Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:13:10 +0100 Subject: [PATCH 08/13] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 1069858f..31cde44b 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -129,7 +129,7 @@ function Test-IdleWorkflowSchema { $ErrorList.Add("'$StepPath.Condition' must be a hashtable (declarative condition object).") } else { - foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Condition) -StepName $StepPath)) { + foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Condition) -StepName $null)) { $ErrorList.Add("'$StepPath.Condition' has invalid condition schema: $schemaError") } } @@ -154,7 +154,9 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - if ($StepCollection -isnot [System.Collections.IEnumerable] -or $StepCollection -is [string]) { + if ($StepCollection -isnot [System.Collections.IEnumerable] -or + $StepCollection -is [string] -or + $StepCollection -is [System.Collections.IDictionary]) { $ErrorList.Add("'$CollectionName' must be an array/list of step hashtables.") return } From 822c53cc1863557af8953216dbaf638e639eaaa4 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:15:25 +0100 Subject: [PATCH 09/13] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 | 2 +- tests/Core/Export-IdlePlan.Tests.ps1 | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 index 8b6e5f59..0249a54b 100644 --- a/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1 @@ -47,7 +47,7 @@ function Assert-IdleConditionPathsResolvable { return } - if ($pathText.StartsWith('context.')) { + if ($pathText.StartsWith('context.', [System.StringComparison]::OrdinalIgnoreCase)) { $pathText = $pathText.Substring(8) } diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index 7ce832d5..3ea6d0d2 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -120,6 +120,7 @@ Describe 'Export-IdlePlan' { $json.plan.warnings[0].code | Should -Be 'PreconditionContextPathUnresolvedAtPlan' $json.plan.warnings[0].step | Should -Be 'Check Context' } + } Context 'Contract invariants' { It 'always includes schemaVersion 1.0' { $cid = '11111111-1111-1111-1111-111111111111' From 212f745d3f4600473fc1fafcc12c46922ac4d88f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:40:01 +0000 Subject: [PATCH 10/13] Initial plan From 4dafdb8a78223f0d6309379978cbcd12fef2f64f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:43:37 +0000 Subject: [PATCH 11/13] Fix invalid dates and redundant step-name prefix in precondition schema errors Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 | 2 +- tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 31cde44b..75671529 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -77,7 +77,7 @@ function Test-IdleWorkflowSchema { $ErrorList.Add("'$StepPath.Precondition' must be a hashtable (condition node).") } else { - foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Precondition) -StepName $StepPath)) { + foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Precondition) -StepName $null)) { $ErrorList.Add("'$StepPath.Precondition' has invalid condition schema: $schemaError") } } diff --git a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 index 96551e97..3b78f425 100644 --- a/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 +++ b/tests/Core/Assert-IdleConditionPathsResolvable.Tests.ps1 @@ -17,7 +17,7 @@ Describe 'Assert-IdleConditionPathsResolvable' { $context = @{ Plan = @{ LifecycleEvent = 'Joiner' } - Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } } { @@ -35,7 +35,7 @@ Describe 'Assert-IdleConditionPathsResolvable' { $context = @{ Plan = @{ LifecycleEvent = 'Joiner' } - Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } } { @@ -53,7 +53,7 @@ Describe 'Assert-IdleConditionPathsResolvable' { $context = @{ Plan = @{ LifecycleEvent = 'Joiner' } - Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } } { @@ -71,7 +71,7 @@ Describe 'Assert-IdleConditionPathsResolvable' { $context = @{ Plan = @{ LifecycleEvent = 'Joiner' } - Request = @{ Intent = @{ OffboardingDate = '2026-02-30' }; Context = @{}; IdentityKeys = @{} } + Request = @{ Intent = @{ OffboardingDate = '2026-02-28' }; Context = @{}; IdentityKeys = @{} } } { From 34f5ff4e720731ae0f2fd558e0f72666e24e7e64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:09:35 +0000 Subject: [PATCH 12/13] Fix bad tests: Export-IdlePlan container failure and planning warnings generation Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/ConvertTo-IdlePlanExportObject.ps1 | 6 ++++++ ...onvertTo-IdleWorkflowStepPreconditionSettings.ps1 | 12 +++++++++++- tests/Core/Export-IdlePlan.Tests.ps1 | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index 9c18cba7..e524ddbe 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -41,6 +41,12 @@ function ConvertTo-IdlePlanExportObject { ) foreach ($name in $Names) { + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($name)) { + return $Object[$name] + } + continue + } $prop = $Object.PSObject.Properties[$name] if ($null -ne $prop) { return $prop.Value diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 index f03f8c31..b5d44813 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowStepPreconditionSettings.ps1 @@ -42,7 +42,17 @@ function ConvertTo-IdleWorkflowStepPreconditionSettings { ) } - Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' -AllowMissingRequestContextPaths -WarningSink (Get-IdlePropertyValue -Object $PlanningContext.Plan -Name 'Warnings') + $warningSink = $null + $planObj = $PlanningContext.Plan + if ($null -ne $planObj) { + if ($planObj -is [System.Collections.IDictionary]) { + if ($planObj.Contains('Warnings')) { $warningSink = $planObj['Warnings'] } + } else { + $wProp = $planObj.PSObject.Properties['Warnings'] + if ($null -ne $wProp) { $warningSink = $wProp.Value } + } + } + Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' -AllowMissingRequestContextPaths -WarningSink $warningSink $normalized.Precondition = Copy-IdleDataObject -Value $rawPrecondition } } diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index 3ea6d0d2..8d45f92c 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -80,7 +80,7 @@ Describe 'Export-IdlePlan' { } } - + Context 'Planning warnings export' { It 'includes planning warnings in exported plan for CI checks' { $cid = '22222222-2222-2222-2222-222222222222' From 30b19a0a9ceddaa3be54c40d598a016944f6071e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:58:32 +0000 Subject: [PATCH 13/13] Fix failing Test-IdleWorkflowSchema tests: escape brackets in -like patterns Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/Core/Test-IdleWorkflowSchema.Tests.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 index 1caf4eb2..37396e44 100644 --- a/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 +++ b/tests/Core/Test-IdleWorkflowSchema.Tests.ps1 @@ -21,7 +21,7 @@ Describe 'Workflow schema validation - Condition/Precondition DSL parity' { } $errors = Test-IdleWorkflowSchema -Workflow $workflow - @($errors | Where-Object { $_ -like "*Steps[0].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + @($errors | Where-Object { $_ -like "*Steps``[0``].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 } It 'rejects invalid Condition DSL nodes in OnFailureSteps at definition validation time' { @@ -41,7 +41,7 @@ Describe 'Workflow schema validation - Condition/Precondition DSL parity' { } $errors = Test-IdleWorkflowSchema -Workflow $workflow - @($errors | Where-Object { $_ -like "*OnFailureSteps[0].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + @($errors | Where-Object { $_ -like "*OnFailureSteps``[0``].Condition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 } It 'rejects invalid Precondition DSL node at definition validation time' { @@ -58,7 +58,7 @@ Describe 'Workflow schema validation - Condition/Precondition DSL parity' { } $errors = Test-IdleWorkflowSchema -Workflow $workflow - @($errors | Where-Object { $_ -like "*Steps[0].Precondition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 + @($errors | Where-Object { $_ -like "*Steps``[0``].Precondition*invalid condition schema*" }).Count | Should -BeGreaterThan 0 } It 'accepts valid precondition using the same condition DSL' { @@ -81,7 +81,7 @@ Describe 'Workflow schema validation - Condition/Precondition DSL parity' { } $errors = Test-IdleWorkflowSchema -Workflow $workflow - $errors.Count | Should -Be 0 + @($errors).Count | Should -Be 0 } } }