From efe4a69668c58697fffe00a41dfec55c4031f89a Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:09:28 +0100 Subject: [PATCH 01/30] core: extend When condition schema validation - remove legacy schema (breaking) --- .../Private/Test-IdleWhenConditionSchema.ps1 | 239 +++++++++++++++++- 1 file changed, 227 insertions(+), 12 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 index 50dec924..dd5d9148 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 @@ -10,26 +10,241 @@ function Test-IdleWhenConditionSchema { [string] $StepName ) + # NOTE: + # This validator is intentionally strict: + # - Unknown keys are errors (keeps configuration deterministic and toolable). + # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In). + # - ScriptBlocks are validated elsewhere (Assert-IdleNoScriptBlock). We assume data-only input here. + $errors = [System.Collections.Generic.List[string]]::new() $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } - if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { - $errors.Add("$($prefix): When requires key 'Path' with a non-empty string value.") - return $errors + function Add-IdleWhenError { + param( + [Parameter(Mandatory)] + [System.Collections.Generic.List[string]] $List, + + [Parameter(Mandatory)] + [string] $Message + ) + + $null = $List.Add($Message) } - # Exactly one operator allowed (MVP) - $ops = @('Equals', 'NotEquals', 'Exists') - $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) + function Test-IdleConditionNodeSchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Node, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $NodePath + ) + + $nodeErrors = [System.Collections.Generic.List[string]]::new() + + if (-not ($Node -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) + return $nodeErrors + } + + $allowedGroupKeys = @('All', 'Any', 'None') + $allowedOpKeys = @('Equals', 'NotEquals', 'Exists', 'In') + $allowedKeys = @($allowedGroupKeys + $allowedOpKeys) + + $presentGroupKeys = @($allowedGroupKeys | Where-Object { $Node.Contains($_) }) + $presentOpKeys = @($allowedOpKeys | Where-Object { $Node.Contains($_) }) + + # Enforce: either group OR operator, never both. + if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) + return $nodeErrors + } + + # Enforce: at least one recognized key. + if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) + return $nodeErrors + } + + # Enforce: exactly one key at this level (avoids ambiguous evaluation). + if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) + return $nodeErrors + } + + # Unknown keys are errors. + foreach ($k in @($Node.Keys)) { + if ($allowedKeys -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k) + } + } + + if ($nodeErrors.Count -gt 0) { + return $nodeErrors + } + + # GROUP: All/Any/None must be a non-empty array/list of condition nodes. + if ($presentGroupKeys.Count -eq 1) { + $groupKey = [string]$presentGroupKeys[0] + $children = $Node[$groupKey] + $groupPath = ("{0}.{1}" -f $NodePath, $groupKey) + + if ($null -eq $children) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) + return $nodeErrors + } + + if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) + return $nodeErrors + } + + $i = 0 + $count = 0 + foreach ($child in @($children)) { + $count++ + foreach ($e in (Test-IdleConditionNodeSchema -Node $child -NodePath ("{0}[{1}]" -f $groupPath, $i))) { + Add-IdleWhenError -List $nodeErrors -Message $e + } + $i++ + } + + if ($count -lt 1) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) + } + + return $nodeErrors + } + + # OPERATOR: Exactly one of Equals/NotEquals/Exists/In. + $opKey = [string]$presentOpKeys[0] + $opVal = $Node[$opKey] + $opPath = ("{0}.{1}" -f $NodePath, $opKey) + + switch ($opKey) { + 'Equals' { + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Left and Right." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Left', 'Right') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + } + + if (-not $opVal.Contains('Right')) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + } + + return $nodeErrors + } + + 'NotEquals' { + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Left and Right." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Left', 'Right') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + } + + if (-not $opVal.Contains('Right')) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + } + + return $nodeErrors + } + + 'Exists' { + # Exists operator supports two forms: + # Exists = 'context.Attributes.mail' + # Exists = @{ Path = 'context.Attributes.mail' } + if ($opVal -is [string]) { + if ([string]::IsNullOrWhiteSpace([string]$opVal)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) + } + return $nodeErrors + } + + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + return $nodeErrors + } + + 'In' { + # In operator: + # In = @{ Left = 'context.Identity.Type'; Right = @('Joiner','Mover') } + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Left and Right." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Left', 'Right') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + } + + if (-not $opVal.Contains('Right')) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + return $nodeErrors + } + + $right = $opVal.Right + if ($null -eq $right) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must not be null." -f $opPath) + return $nodeErrors + } + + # Right should be a list/array (or scalar) but must not be a dictionary (ambiguous). + if ($right -is [System.Collections.IDictionary]) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must be a list/array (or scalar), not a dictionary." -f $opPath) + } + + return $nodeErrors + } + } - if ($presentOps.Count -ne 1) { - $errors.Add("$($prefix): When must specify exactly one operator: Equals, NotEquals, Exists.") - return $errors + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) + return $nodeErrors } - # Exists must be boolean-like - if ($When.ContainsKey('Exists')) { - try { [void][bool]$When.Exists } catch { $errors.Add("$($prefix): When.Exists must be boolean.") } + # Validate recursively from root. + foreach ($e in (Test-IdleConditionNodeSchema -Node $When -NodePath ("{0}: When" -f $prefix))) { + Add-IdleWhenError -List $errors -Message $e } return $errors From e3efcf287c44e5dca4cce906252c23093e0d0ecf Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:09:28 +0100 Subject: [PATCH 02/30] core: extend When condition schema validation - remove legacy schema (breaking) --- .../Private/Test-IdleWhenConditionSchema.ps1 | 239 +++++++++++++++++- 1 file changed, 227 insertions(+), 12 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 index 50dec924..dd5d9148 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 @@ -10,26 +10,241 @@ function Test-IdleWhenConditionSchema { [string] $StepName ) + # NOTE: + # This validator is intentionally strict: + # - Unknown keys are errors (keeps configuration deterministic and toolable). + # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In). + # - ScriptBlocks are validated elsewhere (Assert-IdleNoScriptBlock). We assume data-only input here. + $errors = [System.Collections.Generic.List[string]]::new() $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } - if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { - $errors.Add("$($prefix): When requires key 'Path' with a non-empty string value.") - return $errors + function Add-IdleWhenError { + param( + [Parameter(Mandatory)] + [System.Collections.Generic.List[string]] $List, + + [Parameter(Mandatory)] + [string] $Message + ) + + $null = $List.Add($Message) } - # Exactly one operator allowed (MVP) - $ops = @('Equals', 'NotEquals', 'Exists') - $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) + function Test-IdleConditionNodeSchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Node, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $NodePath + ) + + $nodeErrors = [System.Collections.Generic.List[string]]::new() + + if (-not ($Node -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) + return $nodeErrors + } + + $allowedGroupKeys = @('All', 'Any', 'None') + $allowedOpKeys = @('Equals', 'NotEquals', 'Exists', 'In') + $allowedKeys = @($allowedGroupKeys + $allowedOpKeys) + + $presentGroupKeys = @($allowedGroupKeys | Where-Object { $Node.Contains($_) }) + $presentOpKeys = @($allowedOpKeys | Where-Object { $Node.Contains($_) }) + + # Enforce: either group OR operator, never both. + if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) + return $nodeErrors + } + + # Enforce: at least one recognized key. + if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) + return $nodeErrors + } + + # Enforce: exactly one key at this level (avoids ambiguous evaluation). + if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) + return $nodeErrors + } + + # Unknown keys are errors. + foreach ($k in @($Node.Keys)) { + if ($allowedKeys -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k) + } + } + + if ($nodeErrors.Count -gt 0) { + return $nodeErrors + } + + # GROUP: All/Any/None must be a non-empty array/list of condition nodes. + if ($presentGroupKeys.Count -eq 1) { + $groupKey = [string]$presentGroupKeys[0] + $children = $Node[$groupKey] + $groupPath = ("{0}.{1}" -f $NodePath, $groupKey) + + if ($null -eq $children) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) + return $nodeErrors + } + + if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) + return $nodeErrors + } + + $i = 0 + $count = 0 + foreach ($child in @($children)) { + $count++ + foreach ($e in (Test-IdleConditionNodeSchema -Node $child -NodePath ("{0}[{1}]" -f $groupPath, $i))) { + Add-IdleWhenError -List $nodeErrors -Message $e + } + $i++ + } + + if ($count -lt 1) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) + } + + return $nodeErrors + } + + # OPERATOR: Exactly one of Equals/NotEquals/Exists/In. + $opKey = [string]$presentOpKeys[0] + $opVal = $Node[$opKey] + $opPath = ("{0}.{1}" -f $NodePath, $opKey) + + switch ($opKey) { + 'Equals' { + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Left and Right." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Left', 'Right') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + } + + if (-not $opVal.Contains('Right')) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + } + + return $nodeErrors + } + + 'NotEquals' { + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Left and Right." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Left', 'Right') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + } + + if (-not $opVal.Contains('Right')) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + } + + return $nodeErrors + } + + 'Exists' { + # Exists operator supports two forms: + # Exists = 'context.Attributes.mail' + # Exists = @{ Path = 'context.Attributes.mail' } + if ($opVal -is [string]) { + if ([string]::IsNullOrWhiteSpace([string]$opVal)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) + } + return $nodeErrors + } + + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + return $nodeErrors + } + + 'In' { + # In operator: + # In = @{ Left = 'context.Identity.Type'; Right = @('Joiner','Mover') } + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Left and Right." -f $opPath) + return $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Left', 'Right') -notcontains [string]$k) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + } + + if (-not $opVal.Contains('Right')) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + return $nodeErrors + } + + $right = $opVal.Right + if ($null -eq $right) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must not be null." -f $opPath) + return $nodeErrors + } + + # Right should be a list/array (or scalar) but must not be a dictionary (ambiguous). + if ($right -is [System.Collections.IDictionary]) { + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must be a list/array (or scalar), not a dictionary." -f $opPath) + } + + return $nodeErrors + } + } - if ($presentOps.Count -ne 1) { - $errors.Add("$($prefix): When must specify exactly one operator: Equals, NotEquals, Exists.") - return $errors + Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) + return $nodeErrors } - # Exists must be boolean-like - if ($When.ContainsKey('Exists')) { - try { [void][bool]$When.Exists } catch { $errors.Add("$($prefix): When.Exists must be boolean.") } + # Validate recursively from root. + foreach ($e in (Test-IdleConditionNodeSchema -Node $When -NodePath ("{0}: When" -f $prefix))) { + Add-IdleWhenError -List $errors -Message $e } return $errors From 75b2112d9a43b939e9ced4ab985e5133751618cb Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:14:19 +0100 Subject: [PATCH 03/30] core: evaluate new When condition DSL --- .../Private/Test-IdleWhenCondition.ps1 | 143 +++++++++++++++--- 1 file changed, 121 insertions(+), 22 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 b/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 index d92128aa..f0efe028 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 @@ -10,35 +10,134 @@ function Test-IdleWhenCondition { [object] $Context ) - # Minimal declarative condition schema: - # - Path (string) required - # - Exactly one of: Equals, NotEquals, Exists - if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { - throw [System.ArgumentException]::new("When condition requires key 'Path'.", 'When') - } + # Evaluates a declarative When condition (data-only) against the current execution context. + # + # Supported schema (validated by Test-IdleWhenConditionSchema): + # - Groups: All | Any | None (each contains an array/list of condition nodes) + # - Operators: + # - Equals = @{ Left = ''; Right = } + # - NotEquals = @{ Left = ''; Right = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Left = ''; Right = } + # + # Paths are resolved via Get-IdleValueByPath against the provided $Context. + # For convenience, a leading "context." prefix is ignored (e.g. "context.DesiredState.Department"). + # + # This function is intentionally strict and throws on invalid schema. - $ops = @('Equals', 'NotEquals', 'Exists') - $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) - if ($presentOps.Count -ne 1) { - throw [System.ArgumentException]::new("When condition must specify exactly one operator: Equals, NotEquals, Exists.", 'When') + $schemaErrors = Test-IdleWhenConditionSchema -When $When -StepName $null + if ($schemaErrors.Count -gt 0) { + $msg = "When condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) + throw [System.ArgumentException]::new($msg, 'When') } - $value = Get-IdleValueByPath -Object $Context -Path ([string]$When.Path) + function Resolve-IdleWhenPathValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) - if ($When.ContainsKey('Exists')) { - $expected = [bool]$When.Exists - $actual = ($null -ne $value) - return ($actual -eq $expected) - } + # Allow "context." prefix for readability in config files. + $effectivePath = if ($Path.StartsWith('context.')) { $Path.Substring(8) } else { $Path } - if ($When.ContainsKey('Equals')) { - return ([string]$value -eq [string]$When.Equals) + return Get-IdleValueByPath -Object $Context -Path $effectivePath } - if ($When.ContainsKey('NotEquals')) { - return ([string]$value -ne [string]$When.NotEquals) + function Test-IdleWhenNode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $Node + ) + + # GROUPS + if ($Node.Contains('All')) { + foreach ($child in @($Node.All)) { + if (-not (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child))) { + return $false + } + } + return $true + } + + if ($Node.Contains('Any')) { + foreach ($child in @($Node.Any)) { + if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + return $true + } + } + return $false + } + + if ($Node.Contains('None')) { + foreach ($child in @($Node.None)) { + if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + return $false + } + } + return $true + } + + # OPERATORS + if ($Node.Contains('Equals')) { + $op = $Node.Equals + $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) + $rightValue = $op.Right + + # Keep semantics simple and stable: string comparison. + return ([string]$leftValue -eq [string]$rightValue) + } + + if ($Node.Contains('NotEquals')) { + $op = $Node.NotEquals + $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) + $rightValue = $op.Right + + return ([string]$leftValue -ne [string]$rightValue) + } + + if ($Node.Contains('Exists')) { + $existsVal = $Node.Exists + + $path = if ($existsVal -is [string]) { + [string]$existsVal + } else { + [string]$existsVal.Path + } + + $value = Resolve-IdleWhenPathValue -Path $path + return ($null -ne $value) + } + + if ($Node.Contains('In')) { + $op = $Node.In + $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) + + $right = $op.Right + if ($null -eq $right) { return $false } + + # Treat scalar and array uniformly. + $candidates = if ($right -is [System.Collections.IEnumerable] -and -not ($right -is [string])) { + @($right) + } else { + @($right) + } + + foreach ($candidate in $candidates) { + if ([string]$leftValue -eq [string]$candidate) { + return $true + } + } + + return $false + } + + # Should never happen due to schema validation. + return $false } - # Should never reach here due to validation. - return $false + return (Test-IdleWhenNode -Node $When) } From 4b4321de735b5cd4fd81f4c1b858a3e757c1ee1b Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:19:22 +0100 Subject: [PATCH 04/30] core: rename When to Condition (schema validation) --- ...chema.ps1 => Test-IdleConditionSchema.ps1} | 101 +++++++++--------- 1 file changed, 53 insertions(+), 48 deletions(-) rename src/IdLE.Core/Private/{Test-IdleWhenConditionSchema.ps1 => Test-IdleConditionSchema.ps1} (52%) diff --git a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 similarity index 52% rename from src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 rename to src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 index dd5d9148..fa35a9e2 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 @@ -1,9 +1,9 @@ -function Test-IdleWhenConditionSchema { +function Test-IdleConditionSchema { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [hashtable] $When, + [hashtable] $Condition, [Parameter()] [AllowNull()] @@ -15,11 +15,17 @@ function Test-IdleWhenConditionSchema { # - Unknown keys are errors (keeps configuration deterministic and toolable). # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In). # - ScriptBlocks are validated elsewhere (Assert-IdleNoScriptBlock). We assume data-only input here. + # + # Supported operator shapes: + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Path = ''; Values = } $errors = [System.Collections.Generic.List[string]]::new() $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } - function Add-IdleWhenError { + function Add-IdleConditionError { param( [Parameter(Mandatory)] [System.Collections.Generic.List[string]] $List, @@ -46,7 +52,7 @@ function Test-IdleWhenConditionSchema { $nodeErrors = [System.Collections.Generic.List[string]]::new() if (-not ($Node -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) return $nodeErrors } @@ -59,26 +65,26 @@ function Test-IdleWhenConditionSchema { # Enforce: either group OR operator, never both. if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) return $nodeErrors } # Enforce: at least one recognized key. if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) return $nodeErrors } # Enforce: exactly one key at this level (avoids ambiguous evaluation). if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) return $nodeErrors } # Unknown keys are errors. foreach ($k in @($Node.Keys)) { if ($allowedKeys -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k) } } @@ -93,12 +99,12 @@ function Test-IdleWhenConditionSchema { $groupPath = ("{0}.{1}" -f $NodePath, $groupKey) if ($null -eq $children) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) return $nodeErrors } if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) return $nodeErrors } @@ -107,13 +113,13 @@ function Test-IdleWhenConditionSchema { foreach ($child in @($children)) { $count++ foreach ($e in (Test-IdleConditionNodeSchema -Node $child -NodePath ("{0}[{1}]" -f $groupPath, $i))) { - Add-IdleWhenError -List $nodeErrors -Message $e + Add-IdleConditionError -List $nodeErrors -Message $e } $i++ } if ($count -lt 1) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) } return $nodeErrors @@ -127,22 +133,22 @@ function Test-IdleWhenConditionSchema { switch ($opKey) { 'Equals' { if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Left and Right." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Path and Value." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { - if (@('Left', 'Right') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) } } - if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - if (-not $opVal.Contains('Right')) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } return $nodeErrors @@ -150,22 +156,22 @@ function Test-IdleWhenConditionSchema { 'NotEquals' { if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Left and Right." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Path and Value." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { - if (@('Left', 'Right') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) } } - if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - if (-not $opVal.Contains('Right')) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } return $nodeErrors @@ -177,24 +183,24 @@ function Test-IdleWhenConditionSchema { # Exists = @{ Path = 'context.Attributes.mail' } if ($opVal -is [string]) { if ([string]::IsNullOrWhiteSpace([string]$opVal)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) } return $nodeErrors } if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { if (@('Path') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k) } } if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } return $nodeErrors @@ -202,49 +208,48 @@ function Test-IdleWhenConditionSchema { 'In' { # In operator: - # In = @{ Left = 'context.Identity.Type'; Right = @('Joiner','Mover') } + # In = @{ Path = 'context.Identity.Type'; Values = @('Joiner','Mover') } if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Left and Right." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Path and Values." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { - if (@('Left', 'Right') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + if (@('Path', 'Values') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Values." -f $opPath, [string]$k) } } - if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - if (-not $opVal.Contains('Right')) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + if (-not $opVal.Contains('Values')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Values." -f $opPath) return $nodeErrors } - $right = $opVal.Right - if ($null -eq $right) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must not be null." -f $opPath) + $values = $opVal.Values + if ($null -eq $values) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must not be null." -f $opPath) return $nodeErrors } - # Right should be a list/array (or scalar) but must not be a dictionary (ambiguous). - if ($right -is [System.Collections.IDictionary]) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must be a list/array (or scalar), not a dictionary." -f $opPath) + # Values should be list/array (or scalar) but must not be a dictionary (ambiguous). + if ($values -is [System.Collections.IDictionary]) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must be a list/array (or scalar), not a dictionary." -f $opPath) } return $nodeErrors } } - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) return $nodeErrors } - # Validate recursively from root. - foreach ($e in (Test-IdleConditionNodeSchema -Node $When -NodePath ("{0}: When" -f $prefix))) { - Add-IdleWhenError -List $errors -Message $e + foreach ($e in (Test-IdleConditionNodeSchema -Node $Condition -NodePath ("{0}: Condition" -f $prefix))) { + Add-IdleConditionError -List $errors -Message $e } return $errors From a94ef176d33c34cb96f01fc60450519075b5066d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:21:00 +0100 Subject: [PATCH 05/30] core: evaluate Condition DSL (All/Any/None + Path/Value(s)) --- ...enCondition.ps1 => Test-IdleCondition.ps1} | 71 ++++++++++--------- 1 file changed, 37 insertions(+), 34 deletions(-) rename src/IdLE.Core/Private/{Test-IdleWhenCondition.ps1 => Test-IdleCondition.ps1} (54%) diff --git a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 b/src/IdLE.Core/Private/Test-IdleCondition.ps1 similarity index 54% rename from src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 rename to src/IdLE.Core/Private/Test-IdleCondition.ps1 index f0efe028..8d249cda 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleCondition.ps1 @@ -1,37 +1,35 @@ -function Test-IdleWhenCondition { +function Test-IdleCondition { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [hashtable] $When, + [hashtable] $Condition, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Context ) - # Evaluates a declarative When condition (data-only) against the current execution context. + # Evaluates a declarative Condition (data-only) against the provided context. # - # Supported schema (validated by Test-IdleWhenConditionSchema): + # Supported schema (validated by Test-IdleConditionSchema): # - Groups: All | Any | None (each contains an array/list of condition nodes) # - Operators: - # - Equals = @{ Left = ''; Right = } - # - NotEquals = @{ Left = ''; Right = } + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } # - Exists = '' OR @{ Path = '' } - # - In = @{ Left = ''; Right = } + # - In = @{ Path = ''; Values = } # # Paths are resolved via Get-IdleValueByPath against the provided $Context. - # For convenience, a leading "context." prefix is ignored (e.g. "context.DesiredState.Department"). - # - # This function is intentionally strict and throws on invalid schema. + # For readability in configuration, a leading "context." prefix is ignored. - $schemaErrors = Test-IdleWhenConditionSchema -When $When -StepName $null + $schemaErrors = Test-IdleConditionSchema -Condition $Condition -StepName $null if ($schemaErrors.Count -gt 0) { - $msg = "When condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) - throw [System.ArgumentException]::new($msg, 'When') + $msg = "Condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) + throw [System.ArgumentException]::new($msg, 'Condition') } - function Resolve-IdleWhenPathValue { + function Resolve-IdleConditionPathValue { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -45,7 +43,7 @@ function Test-IdleWhenCondition { return Get-IdleValueByPath -Object $Context -Path $effectivePath } - function Test-IdleWhenNode { + function Test-IdleConditionNode { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -56,7 +54,7 @@ function Test-IdleWhenCondition { # GROUPS if ($Node.Contains('All')) { foreach ($child in @($Node.All)) { - if (-not (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child))) { + if (-not (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child))) { return $false } } @@ -65,7 +63,7 @@ function Test-IdleWhenCondition { if ($Node.Contains('Any')) { foreach ($child in @($Node.Any)) { - if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) { return $true } } @@ -74,7 +72,7 @@ function Test-IdleWhenCondition { if ($Node.Contains('None')) { foreach ($child in @($Node.None)) { - if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) { return $false } } @@ -84,19 +82,21 @@ function Test-IdleWhenCondition { # OPERATORS if ($Node.Contains('Equals')) { $op = $Node.Equals - $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) - $rightValue = $op.Right - # Keep semantics simple and stable: string comparison. - return ([string]$leftValue -eq [string]$rightValue) + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + # Stable semantics: compare as strings (keeps config predictable across providers/types). + return ([string]$actual -eq [string]$expected) } if ($Node.Contains('NotEquals')) { $op = $Node.NotEquals - $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) - $rightValue = $op.Right - return ([string]$leftValue -ne [string]$rightValue) + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + return ([string]$actual -ne [string]$expected) } if ($Node.Contains('Exists')) { @@ -108,26 +108,29 @@ function Test-IdleWhenCondition { [string]$existsVal.Path } - $value = Resolve-IdleWhenPathValue -Path $path + $value = Resolve-IdleConditionPathValue -Path $path return ($null -ne $value) } if ($Node.Contains('In')) { $op = $Node.In - $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) - $right = $op.Right - if ($null -eq $right) { return $false } + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $values = $op.Values + + if ($null -eq $values) { + return $false + } # Treat scalar and array uniformly. - $candidates = if ($right -is [System.Collections.IEnumerable] -and -not ($right -is [string])) { - @($right) + $candidates = if ($values -is [System.Collections.IEnumerable] -and -not ($values -is [string])) { + @($values) } else { - @($right) + @($values) } foreach ($candidate in $candidates) { - if ([string]$leftValue -eq [string]$candidate) { + if ([string]$actual -eq [string]$candidate) { return $true } } @@ -139,5 +142,5 @@ function Test-IdleWhenCondition { return $false } - return (Test-IdleWhenNode -Node $When) + return (Test-IdleConditionNode -Node $Condition) } From 8b5610a25d9dcc80ac19684b5024ad65acd40870 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:23:31 +0100 Subject: [PATCH 06/30] core: add unit tests for Condition schema and evaluator --- tests/Test-IdleCondition.Tests.ps1 | 287 +++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 tests/Test-IdleCondition.Tests.ps1 diff --git a/tests/Test-IdleCondition.Tests.ps1 b/tests/Test-IdleCondition.Tests.ps1 new file mode 100644 index 00000000..36fa8f65 --- /dev/null +++ b/tests/Test-IdleCondition.Tests.ps1 @@ -0,0 +1,287 @@ +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' + Import-Module $modulePath -Force +} + +Describe 'Condition DSL (schema + evaluator)' { + + InModuleScope 'IdLE.Core' { + + Describe 'Test-IdleConditionSchema' { + + It 'accepts an Equals operator with Path + Value' { + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'accepts a nested All group with multiple conditions' { + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ Exists = @{ Path = 'Plan.LifecycleEvent' } } + ) + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'accepts Exists as short form string path' { + $condition = @{ + Exists = 'Plan.LifecycleEvent' + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'accepts In operator with Values as array' { + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + Values = @('Joiner', 'Mover') + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'rejects unknown keys' { + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + Foo = 'Bar' + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects nodes that define both group and operator' { + $condition = @{ + All = @( + @{ + Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } + Any = @() + } + ) + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects group nodes with empty children' { + $condition = @{ + Any = @() + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects Equals with missing Path' { + $condition = @{ + Equals = @{ + Value = 'Joiner' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects In with missing Values' { + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + } + + Describe 'Test-IdleCondition' { + + It 'returns true when Equals matches' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Equals does not match' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'supports context. prefix in paths' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'context.Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns true when Exists finds a non-null value' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Exists = @{ + Path = 'Plan.LifecycleEvent' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Exists cannot resolve the path' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Exists = @{ + Path = 'Plan.DoesNotExist' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'returns true when In matches a candidate value' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + Values = @('Joiner', 'Mover') + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates All as logical AND' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ NotEquals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates Any as logical OR' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Any = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates None as logical NOR' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + None = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Mover' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'throws when schema validation fails' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + # Invalid because Equals is missing Path + $condition = @{ + Equals = @{ + Value = 'Joiner' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw + } + } + } +} From b2c6fb2f1fb01e2313b1c650f00679f5bb9bbe41 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:27:02 +0100 Subject: [PATCH 07/30] tests: rename When tests to Condition --- ...dlePlan.When.Tests.ps1 => Invoke-IdlePlan.Condition.Tests.ps1} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{Invoke-IdlePlan.When.Tests.ps1 => Invoke-IdlePlan.Condition.Tests.ps1} (100%) diff --git a/tests/Invoke-IdlePlan.When.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 similarity index 100% rename from tests/Invoke-IdlePlan.When.Tests.ps1 rename to tests/Invoke-IdlePlan.Condition.Tests.ps1 From 91fa5b940cb46ed5f9fc99ac185d409f9c2613ab Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:27:35 +0100 Subject: [PATCH 08/30] tests: migrate Invoke-IdlePlan condition tests to new Condition DSL --- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 40 ++++++++++++++--------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index 5b5a729c..26dc114b 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -2,7 +2,7 @@ BeforeAll { $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' Import-Module $modulePath -Force - function global:Invoke-IdleWhenTestEmitStep { + function global:Invoke-IdleConditionTestEmitStep { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -28,22 +28,27 @@ BeforeAll { AfterAll { # Cleanup global test functions to avoid polluting the session. - Remove-Item -Path 'Function:\Invoke-IdleWhenTestEmitStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleConditionTestEmitStep' -ErrorAction SilentlyContinue } -Describe 'Invoke-IdlePlan - When conditions' { +Describe 'Invoke-IdlePlan - Condition applicability' { It 'skips a step when condition is not met' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'when.psd1' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'When Demo' + Name = 'Condition Demo' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Emit' - Type = 'IdLE.Step.EmitEvent' - When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Leaver' } + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } } ) } @@ -54,7 +59,7 @@ Describe 'Invoke-IdlePlan - When conditions' { $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' + 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } @@ -67,16 +72,21 @@ Describe 'Invoke-IdlePlan - When conditions' { } It 'runs a step when condition is met' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'when2.psd1' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition2.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'When Demo' + Name = 'Condition Demo' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Emit' - Type = 'IdLE.Step.EmitEvent' - When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Joiner' } + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } } ) } @@ -87,7 +97,7 @@ Describe 'Invoke-IdlePlan - When conditions' { $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' + 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } From c93156663aa3ed65e763652a20b6c525cae9c8cd Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:36:23 +0100 Subject: [PATCH 09/30] core: evaluate step conditions during planning (NotApplicable) --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 69 ++++++++++++++++----- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 077fe88a..4b5a6f33 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -46,34 +46,73 @@ function New-IdlePlanObject { # Validate workflow and ensure it matches the request's LifecycleEvent. $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + # Create the plan object (planning artifact). + # Steps will be populated after we have a stable plan context for condition evaluation. + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + WorkflowName = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + CorrelationId = [string]$Request.CorrelationId + Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } + CreatedUtc = [DateTime]::UtcNow + Steps = @() + Actions = @() + Warnings = @() + Providers = $Providers + } + + # Build a planning context for condition evaluation. + # This allows conditions to reference "Plan.*" paths (e.g. Plan.LifecycleEvent). + $planningContext = [pscustomobject]@{ + Plan = $plan + Request = $Request + Workflow = $workflow + } + # Normalize steps into a stable internal representation. # We deliberately keep step entries as PSCustomObject to avoid cross-module class loading issues. + # Step conditions are evaluated during planning and may mark steps as NotApplicable. $normalizedSteps = @() foreach ($s in @($workflow.Steps)) { + + # Breaking change: "When" is no longer supported. Use "Condition" instead. + if ($s.ContainsKey('When')) { + throw [System.ArgumentException]::new( + "Workflow step '$($s.Name)' uses key 'When'. This has been renamed to 'Condition'. Please update the workflow definition.", + 'Workflow' + ) + } + + $condition = if ($s.ContainsKey('Condition')) { $s.Condition } else { $null } + + $status = 'Planned' + if ($null -ne $condition) { + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName ([string]$s.Name) + if ($schemaErrors.Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Condition on step '{0}': {1}" -f [string]$s.Name, ([string]::Join(' ', @($schemaErrors)))), + 'Workflow' + ) + } + + $isApplicable = Test-IdleCondition -Condition $condition -Context $planningContext + if (-not $isApplicable) { + $status = 'NotApplicable' + } + } + $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = [string]$s.Name Type = [string]$s.Type Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { $null } - When = if ($s.ContainsKey('When')) { $s.When } else { $null } # Declarative; evaluated later. + Condition = $condition With = if ($s.ContainsKey('With')) { $s.With } else { $null } # Parameter bag; validated later. + Status = $status } } - # Create the plan object. Actions are empty in this increment. - # Warnings are an extensibility point (e.g. missing optional inputs). - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - WorkflowName = [string]$workflow.Name - LifecycleEvent = [string]$workflow.LifecycleEvent - CorrelationId = [string]$Request.CorrelationId - Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } - CreatedUtc = [DateTime]::UtcNow - Steps = $normalizedSteps - Actions = @() - Warnings = @() - Providers = $Providers - } + $plan.Steps = $normalizedSteps return $plan } From 8fc6a96321d14c83e405d9fc7b8732cb539cb69d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:41:28 +0100 Subject: [PATCH 10/30] a --- src/IdLE/Public/Invoke-IdlePlan.ps1 | 35 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index d798aae6..1b387b6b 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -20,33 +20,40 @@ function Invoke-IdlePlan { Invoke-IdlePlan -Plan $plan -Providers $providers .OUTPUTS - System.Object + PSCustomObject (PSTypeName: IdLE.ExecutionResult) #> - [CmdletBinding(SupportsShouldProcess)] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( - [Parameter(Mandatory)] + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] [object] $Plan, [Parameter()] [AllowNull()] - [object] $Providers, + [hashtable] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { - # For WhatIf: return a minimal preview object. - return [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionResult' - Status = 'WhatIf' - CorrelationId = if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { [string]$Plan.CorrelationId } else { $null } - Steps = @($Plan.Steps) - Events = @() + process { + if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { + # For -WhatIf: return a minimal preview object. + $correlationId = $null + if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { + $correlationId = [string]$Plan.CorrelationId + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionResult' + Status = 'WhatIf' + CorrelationId = $correlationId + Steps = @($Plan.Steps) + Events = @() + } } - } - return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink + return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink + } } From 235cf779ef56627dd136e54f79999d335329682d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:48:33 +0100 Subject: [PATCH 11/30] core: skip NotApplicable steps during execution --- .../Public/Invoke-IdlePlanObject.ps1 | 100 ++++++++++-------- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 6 +- 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index b9dd8738..aff0ac80 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -14,7 +14,7 @@ function Invoke-IdlePlanObject { Provider registry/collection (used for StepRegistry in this increment; passed through for future steps). .PARAMETER EventSink - Optional external event sink for streaming. Must be an object with a WriteEvent(event) method. + Optional external event sink provided by the host. .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) @@ -27,29 +27,22 @@ function Invoke-IdlePlanObject { [Parameter()] [AllowNull()] - [object] $Providers, + [hashtable] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - # Validate minimal plan shape. Avoid hard typing to keep cross-module compatibility. - $planProps = $Plan.PSObject.Properties.Name - foreach ($required in @('CorrelationId', 'LifecycleEvent', 'Steps')) { - if ($planProps -notcontains $required) { - throw [System.ArgumentException]::new("Plan object must contain property '$required'.", 'Plan') - } - } + $planProps = @($Plan.PSObject.Properties.Name) - # Secure default: treat host-provided extension points as privileged inputs. # The engine rejects ScriptBlocks in the plan and providers to avoid accidental code execution. Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' $events = [System.Collections.Generic.List[object]]::new() - $corr = [string]$Plan.CorrelationId + $corr = [string]$Plan.CorrelationId $actor = if ($planProps -contains 'Actor') { [string]$Plan.Actor } else { $null } # Create the engine-managed event sink object used by both the engine and steps. @@ -58,14 +51,13 @@ function Invoke-IdlePlanObject { # Resolve step types to PowerShell functions via a registry. # This decouples workflow "Type" strings from actual implementation functions. - $registry = Get-IdleStepRegistry -Providers $Providers + $stepRegistry = $null + if ($null -ne $Providers -and $Providers.ContainsKey('StepRegistry')) { + $stepRegistry = $Providers.StepRegistry + } - # Provide a small execution context for steps. - # Steps must not call engine-private functions directly; they only use the context. $context = [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionContext' - CorrelationId = $corr - Actor = $actor Plan = $Plan Providers = $Providers @@ -77,12 +69,12 @@ function Invoke-IdlePlanObject { # Emit run start event. $context.EventSink.WriteEvent('RunStarted', 'Plan execution started.', $null, @{ LifecycleEvent = [string]$Plan.LifecycleEvent - WorkflowName = if ($planProps -contains 'WorkflowName') { + WorkflowName = if ($planProps -contains 'WorkflowName') { [string]$Plan.WorkflowName } else { $null } - StepCount = @($Plan.Steps).Count + StepCount = @($Plan.Steps).Count }) $stepResults = @() @@ -97,26 +89,33 @@ function Invoke-IdlePlanObject { $null } - # Evaluate declarative When condition (data-only). - if ($step.PSObject.Properties.Name -contains 'When' -and $null -ne $step.When) { - $shouldRun = Test-IdleWhenCondition -When $step.When -Context $context - if (-not $shouldRun) { - $stepResults += [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'Skipped' - Error = $null - } - - $context.EventSink.WriteEvent('StepSkipped', "Step '$stepName' skipped (condition not met).", $stepName, @{ - StepType = $stepType - Index = $i - }) - - $i++ - continue + # Step applicability is evaluated during planning (New-IdlePlanObject). + # At execution time we only respect the planned status. + if ($step.PSObject.Properties.Name -contains 'When') { + throw [System.ArgumentException]::new( + "Plan step '$stepName' still contains legacy key 'When'. This has been renamed to 'Condition'. Please rebuild the plan with an updated workflow definition.", + 'Plan' + ) + } + + if ($step.PSObject.Properties.Name -contains 'Status' -and [string]$step.Status -eq 'NotApplicable') { + + # Synthetic step result: the step was not executed because it was deemed not applicable at plan time. + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'NotApplicable' + Error = $null } + + $context.EventSink.WriteEvent('StepNotApplicable', "Step '$stepName' not applicable (condition not met).", $stepName, @{ + StepType = $stepType + Index = $i + }) + + $i++ + continue } $context.EventSink.WriteEvent('StepStarted', "Step '$stepName' started.", $stepName, @{ @@ -127,15 +126,30 @@ function Invoke-IdlePlanObject { try { # Resolve implementation handler for this step type. # Handler must be a function name (string). - $handler = Resolve-IdleStepHandler -StepType $stepType -Registry $registry - if ($null -eq $handler) { - throw [System.InvalidOperationException]::new("Step type '$stepType' is not registered.") + if ($null -eq $stepType -or [string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new("Step '$stepName' is missing a valid Type.", 'Plan') } - # Invoke the step plugin. - $stepResult = & $handler -Context $context -Step $step + if ($null -eq $stepRegistry -or -not ($stepRegistry.ContainsKey($stepType))) { + throw [System.ArgumentException]::new("No step handler registered for type '$stepType'.", 'Providers') + } + + $handlerName = [string]$stepRegistry[$stepType] + if ([string]::IsNullOrWhiteSpace($handlerName)) { + throw [System.ArgumentException]::new("Step handler for type '$stepType' is not a valid function name.", 'Providers') + } - $stepResults += $stepResult + # Execute the step via handler. + $result = & $handlerName -Context $context -Step $step + + # Normalize result shape (minimal contract). + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Status') { [string]$result.Status } else { 'Completed' } + Error = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Error') { $result.Error } else { $null } + } $context.EventSink.WriteEvent('StepCompleted', "Step '$stepName' completed.", $stepName, @{ StepType = $stepType diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index 26dc114b..2d980313 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -33,7 +33,7 @@ AfterAll { Describe 'Invoke-IdlePlan - Condition applicability' { - It 'skips a step when condition is not met' { + It 'does not execute a step when plan marks it as NotApplicable' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ @@ -66,9 +66,9 @@ Describe 'Invoke-IdlePlan - Condition applicability' { $result = Invoke-IdlePlan -Plan $plan -Providers $providers $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Skipped' + $result.Steps[0].Status | Should -Be 'NotApplicable' ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 - ($result.Events | Where-Object Type -eq 'StepSkipped').Count | Should -Be 1 + ($result.Events | Where-Object Type -eq 'StepNotApplicable').Count | Should -Be 1 } It 'runs a step when condition is met' { From b824b1e3c808ff91d802c1e9e78491364fd54e88 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:14:19 +0100 Subject: [PATCH 12/30] core: evaluate new When condition DSL --- .../Private/Test-IdleWhenCondition.ps1 | 143 +++++++++++++++--- 1 file changed, 121 insertions(+), 22 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 b/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 index d92128aa..f0efe028 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 @@ -10,35 +10,134 @@ function Test-IdleWhenCondition { [object] $Context ) - # Minimal declarative condition schema: - # - Path (string) required - # - Exactly one of: Equals, NotEquals, Exists - if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { - throw [System.ArgumentException]::new("When condition requires key 'Path'.", 'When') - } + # Evaluates a declarative When condition (data-only) against the current execution context. + # + # Supported schema (validated by Test-IdleWhenConditionSchema): + # - Groups: All | Any | None (each contains an array/list of condition nodes) + # - Operators: + # - Equals = @{ Left = ''; Right = } + # - NotEquals = @{ Left = ''; Right = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Left = ''; Right = } + # + # Paths are resolved via Get-IdleValueByPath against the provided $Context. + # For convenience, a leading "context." prefix is ignored (e.g. "context.DesiredState.Department"). + # + # This function is intentionally strict and throws on invalid schema. - $ops = @('Equals', 'NotEquals', 'Exists') - $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) - if ($presentOps.Count -ne 1) { - throw [System.ArgumentException]::new("When condition must specify exactly one operator: Equals, NotEquals, Exists.", 'When') + $schemaErrors = Test-IdleWhenConditionSchema -When $When -StepName $null + if ($schemaErrors.Count -gt 0) { + $msg = "When condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) + throw [System.ArgumentException]::new($msg, 'When') } - $value = Get-IdleValueByPath -Object $Context -Path ([string]$When.Path) + function Resolve-IdleWhenPathValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) - if ($When.ContainsKey('Exists')) { - $expected = [bool]$When.Exists - $actual = ($null -ne $value) - return ($actual -eq $expected) - } + # Allow "context." prefix for readability in config files. + $effectivePath = if ($Path.StartsWith('context.')) { $Path.Substring(8) } else { $Path } - if ($When.ContainsKey('Equals')) { - return ([string]$value -eq [string]$When.Equals) + return Get-IdleValueByPath -Object $Context -Path $effectivePath } - if ($When.ContainsKey('NotEquals')) { - return ([string]$value -ne [string]$When.NotEquals) + function Test-IdleWhenNode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $Node + ) + + # GROUPS + if ($Node.Contains('All')) { + foreach ($child in @($Node.All)) { + if (-not (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child))) { + return $false + } + } + return $true + } + + if ($Node.Contains('Any')) { + foreach ($child in @($Node.Any)) { + if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + return $true + } + } + return $false + } + + if ($Node.Contains('None')) { + foreach ($child in @($Node.None)) { + if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + return $false + } + } + return $true + } + + # OPERATORS + if ($Node.Contains('Equals')) { + $op = $Node.Equals + $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) + $rightValue = $op.Right + + # Keep semantics simple and stable: string comparison. + return ([string]$leftValue -eq [string]$rightValue) + } + + if ($Node.Contains('NotEquals')) { + $op = $Node.NotEquals + $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) + $rightValue = $op.Right + + return ([string]$leftValue -ne [string]$rightValue) + } + + if ($Node.Contains('Exists')) { + $existsVal = $Node.Exists + + $path = if ($existsVal -is [string]) { + [string]$existsVal + } else { + [string]$existsVal.Path + } + + $value = Resolve-IdleWhenPathValue -Path $path + return ($null -ne $value) + } + + if ($Node.Contains('In')) { + $op = $Node.In + $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) + + $right = $op.Right + if ($null -eq $right) { return $false } + + # Treat scalar and array uniformly. + $candidates = if ($right -is [System.Collections.IEnumerable] -and -not ($right -is [string])) { + @($right) + } else { + @($right) + } + + foreach ($candidate in $candidates) { + if ([string]$leftValue -eq [string]$candidate) { + return $true + } + } + + return $false + } + + # Should never happen due to schema validation. + return $false } - # Should never reach here due to validation. - return $false + return (Test-IdleWhenNode -Node $When) } From b5e4c53ecaebf4c8564050b3a6f0c4d0b4f32dcf Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:19:22 +0100 Subject: [PATCH 13/30] core: rename When to Condition (schema validation) --- ...chema.ps1 => Test-IdleConditionSchema.ps1} | 101 +++++++++--------- 1 file changed, 53 insertions(+), 48 deletions(-) rename src/IdLE.Core/Private/{Test-IdleWhenConditionSchema.ps1 => Test-IdleConditionSchema.ps1} (52%) diff --git a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 similarity index 52% rename from src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 rename to src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 index dd5d9148..fa35a9e2 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 @@ -1,9 +1,9 @@ -function Test-IdleWhenConditionSchema { +function Test-IdleConditionSchema { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [hashtable] $When, + [hashtable] $Condition, [Parameter()] [AllowNull()] @@ -15,11 +15,17 @@ function Test-IdleWhenConditionSchema { # - Unknown keys are errors (keeps configuration deterministic and toolable). # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In). # - ScriptBlocks are validated elsewhere (Assert-IdleNoScriptBlock). We assume data-only input here. + # + # Supported operator shapes: + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Path = ''; Values = } $errors = [System.Collections.Generic.List[string]]::new() $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } - function Add-IdleWhenError { + function Add-IdleConditionError { param( [Parameter(Mandatory)] [System.Collections.Generic.List[string]] $List, @@ -46,7 +52,7 @@ function Test-IdleWhenConditionSchema { $nodeErrors = [System.Collections.Generic.List[string]]::new() if (-not ($Node -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) return $nodeErrors } @@ -59,26 +65,26 @@ function Test-IdleWhenConditionSchema { # Enforce: either group OR operator, never both. if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) return $nodeErrors } # Enforce: at least one recognized key. if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) return $nodeErrors } # Enforce: exactly one key at this level (avoids ambiguous evaluation). if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) return $nodeErrors } # Unknown keys are errors. foreach ($k in @($Node.Keys)) { if ($allowedKeys -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k) } } @@ -93,12 +99,12 @@ function Test-IdleWhenConditionSchema { $groupPath = ("{0}.{1}" -f $NodePath, $groupKey) if ($null -eq $children) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) return $nodeErrors } if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) return $nodeErrors } @@ -107,13 +113,13 @@ function Test-IdleWhenConditionSchema { foreach ($child in @($children)) { $count++ foreach ($e in (Test-IdleConditionNodeSchema -Node $child -NodePath ("{0}[{1}]" -f $groupPath, $i))) { - Add-IdleWhenError -List $nodeErrors -Message $e + Add-IdleConditionError -List $nodeErrors -Message $e } $i++ } if ($count -lt 1) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) } return $nodeErrors @@ -127,22 +133,22 @@ function Test-IdleWhenConditionSchema { switch ($opKey) { 'Equals' { if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Left and Right." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Path and Value." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { - if (@('Left', 'Right') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) } } - if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - if (-not $opVal.Contains('Right')) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } return $nodeErrors @@ -150,22 +156,22 @@ function Test-IdleWhenConditionSchema { 'NotEquals' { if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Left and Right." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Path and Value." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { - if (@('Left', 'Right') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) } } - if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - if (-not $opVal.Contains('Right')) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } return $nodeErrors @@ -177,24 +183,24 @@ function Test-IdleWhenConditionSchema { # Exists = @{ Path = 'context.Attributes.mail' } if ($opVal -is [string]) { if ([string]::IsNullOrWhiteSpace([string]$opVal)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) } return $nodeErrors } if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { if (@('Path') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k) } } if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } return $nodeErrors @@ -202,49 +208,48 @@ function Test-IdleWhenConditionSchema { 'In' { # In operator: - # In = @{ Left = 'context.Identity.Type'; Right = @('Joiner','Mover') } + # In = @{ Path = 'context.Identity.Type'; Values = @('Joiner','Mover') } if (-not ($opVal -is [System.Collections.IDictionary])) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Left and Right." -f $opPath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Path and Values." -f $opPath) return $nodeErrors } foreach ($k in @($opVal.Keys)) { - if (@('Left', 'Right') -notcontains [string]$k) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Left, Right." -f $opPath, [string]$k) + if (@('Path', 'Values') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Values." -f $opPath, [string]$k) } } - if (-not $opVal.Contains('Left') -or [string]::IsNullOrWhiteSpace([string]$opVal.Left)) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing or empty Left." -f $opPath) + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - if (-not $opVal.Contains('Right')) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Missing Right." -f $opPath) + if (-not $opVal.Contains('Values')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Values." -f $opPath) return $nodeErrors } - $right = $opVal.Right - if ($null -eq $right) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must not be null." -f $opPath) + $values = $opVal.Values + if ($null -eq $values) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must not be null." -f $opPath) return $nodeErrors } - # Right should be a list/array (or scalar) but must not be a dictionary (ambiguous). - if ($right -is [System.Collections.IDictionary]) { - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Right must be a list/array (or scalar), not a dictionary." -f $opPath) + # Values should be list/array (or scalar) but must not be a dictionary (ambiguous). + if ($values -is [System.Collections.IDictionary]) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must be a list/array (or scalar), not a dictionary." -f $opPath) } return $nodeErrors } } - Add-IdleWhenError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) return $nodeErrors } - # Validate recursively from root. - foreach ($e in (Test-IdleConditionNodeSchema -Node $When -NodePath ("{0}: When" -f $prefix))) { - Add-IdleWhenError -List $errors -Message $e + foreach ($e in (Test-IdleConditionNodeSchema -Node $Condition -NodePath ("{0}: Condition" -f $prefix))) { + Add-IdleConditionError -List $errors -Message $e } return $errors From 8bddc85a7998d7acb5b0b4ac52319f2fd45f1d89 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:21:00 +0100 Subject: [PATCH 14/30] core: evaluate Condition DSL (All/Any/None + Path/Value(s)) --- ...enCondition.ps1 => Test-IdleCondition.ps1} | 71 ++++++++++--------- 1 file changed, 37 insertions(+), 34 deletions(-) rename src/IdLE.Core/Private/{Test-IdleWhenCondition.ps1 => Test-IdleCondition.ps1} (54%) diff --git a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 b/src/IdLE.Core/Private/Test-IdleCondition.ps1 similarity index 54% rename from src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 rename to src/IdLE.Core/Private/Test-IdleCondition.ps1 index f0efe028..8d249cda 100644 --- a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleCondition.ps1 @@ -1,37 +1,35 @@ -function Test-IdleWhenCondition { +function Test-IdleCondition { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] - [hashtable] $When, + [hashtable] $Condition, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Context ) - # Evaluates a declarative When condition (data-only) against the current execution context. + # Evaluates a declarative Condition (data-only) against the provided context. # - # Supported schema (validated by Test-IdleWhenConditionSchema): + # Supported schema (validated by Test-IdleConditionSchema): # - Groups: All | Any | None (each contains an array/list of condition nodes) # - Operators: - # - Equals = @{ Left = ''; Right = } - # - NotEquals = @{ Left = ''; Right = } + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } # - Exists = '' OR @{ Path = '' } - # - In = @{ Left = ''; Right = } + # - In = @{ Path = ''; Values = } # # Paths are resolved via Get-IdleValueByPath against the provided $Context. - # For convenience, a leading "context." prefix is ignored (e.g. "context.DesiredState.Department"). - # - # This function is intentionally strict and throws on invalid schema. + # For readability in configuration, a leading "context." prefix is ignored. - $schemaErrors = Test-IdleWhenConditionSchema -When $When -StepName $null + $schemaErrors = Test-IdleConditionSchema -Condition $Condition -StepName $null if ($schemaErrors.Count -gt 0) { - $msg = "When condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) - throw [System.ArgumentException]::new($msg, 'When') + $msg = "Condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) + throw [System.ArgumentException]::new($msg, 'Condition') } - function Resolve-IdleWhenPathValue { + function Resolve-IdleConditionPathValue { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -45,7 +43,7 @@ function Test-IdleWhenCondition { return Get-IdleValueByPath -Object $Context -Path $effectivePath } - function Test-IdleWhenNode { + function Test-IdleConditionNode { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -56,7 +54,7 @@ function Test-IdleWhenCondition { # GROUPS if ($Node.Contains('All')) { foreach ($child in @($Node.All)) { - if (-not (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child))) { + if (-not (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child))) { return $false } } @@ -65,7 +63,7 @@ function Test-IdleWhenCondition { if ($Node.Contains('Any')) { foreach ($child in @($Node.Any)) { - if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) { return $true } } @@ -74,7 +72,7 @@ function Test-IdleWhenCondition { if ($Node.Contains('None')) { foreach ($child in @($Node.None)) { - if (Test-IdleWhenNode -Node ([System.Collections.IDictionary]$child)) { + if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) { return $false } } @@ -84,19 +82,21 @@ function Test-IdleWhenCondition { # OPERATORS if ($Node.Contains('Equals')) { $op = $Node.Equals - $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) - $rightValue = $op.Right - # Keep semantics simple and stable: string comparison. - return ([string]$leftValue -eq [string]$rightValue) + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + # Stable semantics: compare as strings (keeps config predictable across providers/types). + return ([string]$actual -eq [string]$expected) } if ($Node.Contains('NotEquals')) { $op = $Node.NotEquals - $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) - $rightValue = $op.Right - return ([string]$leftValue -ne [string]$rightValue) + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + return ([string]$actual -ne [string]$expected) } if ($Node.Contains('Exists')) { @@ -108,26 +108,29 @@ function Test-IdleWhenCondition { [string]$existsVal.Path } - $value = Resolve-IdleWhenPathValue -Path $path + $value = Resolve-IdleConditionPathValue -Path $path return ($null -ne $value) } if ($Node.Contains('In')) { $op = $Node.In - $leftValue = Resolve-IdleWhenPathValue -Path ([string]$op.Left) - $right = $op.Right - if ($null -eq $right) { return $false } + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $values = $op.Values + + if ($null -eq $values) { + return $false + } # Treat scalar and array uniformly. - $candidates = if ($right -is [System.Collections.IEnumerable] -and -not ($right -is [string])) { - @($right) + $candidates = if ($values -is [System.Collections.IEnumerable] -and -not ($values -is [string])) { + @($values) } else { - @($right) + @($values) } foreach ($candidate in $candidates) { - if ([string]$leftValue -eq [string]$candidate) { + if ([string]$actual -eq [string]$candidate) { return $true } } @@ -139,5 +142,5 @@ function Test-IdleWhenCondition { return $false } - return (Test-IdleWhenNode -Node $When) + return (Test-IdleConditionNode -Node $Condition) } From 1b862fc1ac027202f9ad295589fe41b45f131dba Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:23:31 +0100 Subject: [PATCH 15/30] core: add unit tests for Condition schema and evaluator --- tests/Test-IdleCondition.Tests.ps1 | 287 +++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 tests/Test-IdleCondition.Tests.ps1 diff --git a/tests/Test-IdleCondition.Tests.ps1 b/tests/Test-IdleCondition.Tests.ps1 new file mode 100644 index 00000000..36fa8f65 --- /dev/null +++ b/tests/Test-IdleCondition.Tests.ps1 @@ -0,0 +1,287 @@ +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' + Import-Module $modulePath -Force +} + +Describe 'Condition DSL (schema + evaluator)' { + + InModuleScope 'IdLE.Core' { + + Describe 'Test-IdleConditionSchema' { + + It 'accepts an Equals operator with Path + Value' { + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'accepts a nested All group with multiple conditions' { + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ Exists = @{ Path = 'Plan.LifecycleEvent' } } + ) + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'accepts Exists as short form string path' { + $condition = @{ + Exists = 'Plan.LifecycleEvent' + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'accepts In operator with Values as array' { + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + Values = @('Joiner', 'Mover') + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors | Should -BeEmpty + } + + It 'rejects unknown keys' { + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + Foo = 'Bar' + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects nodes that define both group and operator' { + $condition = @{ + All = @( + @{ + Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } + Any = @() + } + ) + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects group nodes with empty children' { + $condition = @{ + Any = @() + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects Equals with missing Path' { + $condition = @{ + Equals = @{ + Value = 'Joiner' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects In with missing Values' { + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + } + + Describe 'Test-IdleCondition' { + + It 'returns true when Equals matches' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Equals does not match' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'supports context. prefix in paths' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'context.Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns true when Exists finds a non-null value' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Exists = @{ + Path = 'Plan.LifecycleEvent' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Exists cannot resolve the path' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Exists = @{ + Path = 'Plan.DoesNotExist' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'returns true when In matches a candidate value' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + Values = @('Joiner', 'Mover') + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates All as logical AND' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ NotEquals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates Any as logical OR' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Any = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates None as logical NOR' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + None = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Mover' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'throws when schema validation fails' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + # Invalid because Equals is missing Path + $condition = @{ + Equals = @{ + Value = 'Joiner' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw + } + } + } +} From aacecd9f2c2680146296f9a2e9fd2968ae453f58 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:27:02 +0100 Subject: [PATCH 16/30] tests: rename When tests to Condition --- ...dlePlan.When.Tests.ps1 => Invoke-IdlePlan.Condition.Tests.ps1} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{Invoke-IdlePlan.When.Tests.ps1 => Invoke-IdlePlan.Condition.Tests.ps1} (100%) diff --git a/tests/Invoke-IdlePlan.When.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 similarity index 100% rename from tests/Invoke-IdlePlan.When.Tests.ps1 rename to tests/Invoke-IdlePlan.Condition.Tests.ps1 From 49402409a66425e6a58678a2bf5762b3f0baddf6 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:27:35 +0100 Subject: [PATCH 17/30] tests: migrate Invoke-IdlePlan condition tests to new Condition DSL --- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 40 ++++++++++++++--------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index 5b5a729c..26dc114b 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -2,7 +2,7 @@ BeforeAll { $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' Import-Module $modulePath -Force - function global:Invoke-IdleWhenTestEmitStep { + function global:Invoke-IdleConditionTestEmitStep { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -28,22 +28,27 @@ BeforeAll { AfterAll { # Cleanup global test functions to avoid polluting the session. - Remove-Item -Path 'Function:\Invoke-IdleWhenTestEmitStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleConditionTestEmitStep' -ErrorAction SilentlyContinue } -Describe 'Invoke-IdlePlan - When conditions' { +Describe 'Invoke-IdlePlan - Condition applicability' { It 'skips a step when condition is not met' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'when.psd1' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'When Demo' + Name = 'Condition Demo' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Emit' - Type = 'IdLE.Step.EmitEvent' - When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Leaver' } + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } } ) } @@ -54,7 +59,7 @@ Describe 'Invoke-IdlePlan - When conditions' { $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' + 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } @@ -67,16 +72,21 @@ Describe 'Invoke-IdlePlan - When conditions' { } It 'runs a step when condition is met' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'when2.psd1' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition2.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'When Demo' + Name = 'Condition Demo' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Emit' - Type = 'IdLE.Step.EmitEvent' - When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Joiner' } + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } } ) } @@ -87,7 +97,7 @@ Describe 'Invoke-IdlePlan - When conditions' { $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' + 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } From b6670df7a937b3177b0d38dda60bfca5dbc985a5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:36:23 +0100 Subject: [PATCH 18/30] core: evaluate step conditions during planning (NotApplicable) --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 69 ++++++++++++++++----- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 077fe88a..4b5a6f33 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -46,34 +46,73 @@ function New-IdlePlanObject { # Validate workflow and ensure it matches the request's LifecycleEvent. $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + # Create the plan object (planning artifact). + # Steps will be populated after we have a stable plan context for condition evaluation. + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + WorkflowName = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + CorrelationId = [string]$Request.CorrelationId + Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } + CreatedUtc = [DateTime]::UtcNow + Steps = @() + Actions = @() + Warnings = @() + Providers = $Providers + } + + # Build a planning context for condition evaluation. + # This allows conditions to reference "Plan.*" paths (e.g. Plan.LifecycleEvent). + $planningContext = [pscustomobject]@{ + Plan = $plan + Request = $Request + Workflow = $workflow + } + # Normalize steps into a stable internal representation. # We deliberately keep step entries as PSCustomObject to avoid cross-module class loading issues. + # Step conditions are evaluated during planning and may mark steps as NotApplicable. $normalizedSteps = @() foreach ($s in @($workflow.Steps)) { + + # Breaking change: "When" is no longer supported. Use "Condition" instead. + if ($s.ContainsKey('When')) { + throw [System.ArgumentException]::new( + "Workflow step '$($s.Name)' uses key 'When'. This has been renamed to 'Condition'. Please update the workflow definition.", + 'Workflow' + ) + } + + $condition = if ($s.ContainsKey('Condition')) { $s.Condition } else { $null } + + $status = 'Planned' + if ($null -ne $condition) { + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName ([string]$s.Name) + if ($schemaErrors.Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Condition on step '{0}': {1}" -f [string]$s.Name, ([string]::Join(' ', @($schemaErrors)))), + 'Workflow' + ) + } + + $isApplicable = Test-IdleCondition -Condition $condition -Context $planningContext + if (-not $isApplicable) { + $status = 'NotApplicable' + } + } + $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = [string]$s.Name Type = [string]$s.Type Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { $null } - When = if ($s.ContainsKey('When')) { $s.When } else { $null } # Declarative; evaluated later. + Condition = $condition With = if ($s.ContainsKey('With')) { $s.With } else { $null } # Parameter bag; validated later. + Status = $status } } - # Create the plan object. Actions are empty in this increment. - # Warnings are an extensibility point (e.g. missing optional inputs). - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - WorkflowName = [string]$workflow.Name - LifecycleEvent = [string]$workflow.LifecycleEvent - CorrelationId = [string]$Request.CorrelationId - Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } - CreatedUtc = [DateTime]::UtcNow - Steps = $normalizedSteps - Actions = @() - Warnings = @() - Providers = $Providers - } + $plan.Steps = $normalizedSteps return $plan } From 99a62d375c1f4d7c4f03dbc0ce2c739ab11b5680 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:41:28 +0100 Subject: [PATCH 19/30] a --- src/IdLE/Public/Invoke-IdlePlan.ps1 | 35 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index d798aae6..1b387b6b 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -20,33 +20,40 @@ function Invoke-IdlePlan { Invoke-IdlePlan -Plan $plan -Providers $providers .OUTPUTS - System.Object + PSCustomObject (PSTypeName: IdLE.ExecutionResult) #> - [CmdletBinding(SupportsShouldProcess)] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( - [Parameter(Mandatory)] + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] [object] $Plan, [Parameter()] [AllowNull()] - [object] $Providers, + [hashtable] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { - # For WhatIf: return a minimal preview object. - return [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionResult' - Status = 'WhatIf' - CorrelationId = if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { [string]$Plan.CorrelationId } else { $null } - Steps = @($Plan.Steps) - Events = @() + process { + if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { + # For -WhatIf: return a minimal preview object. + $correlationId = $null + if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { + $correlationId = [string]$Plan.CorrelationId + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionResult' + Status = 'WhatIf' + CorrelationId = $correlationId + Steps = @($Plan.Steps) + Events = @() + } } - } - return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink + return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink + } } From 1170ec5a35a74c86e882f4b813ff78b5316c0efe Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:48:33 +0100 Subject: [PATCH 20/30] core: skip NotApplicable steps during execution --- .../Public/Invoke-IdlePlanObject.ps1 | 100 ++++++++++-------- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 6 +- 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index b9dd8738..aff0ac80 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -14,7 +14,7 @@ function Invoke-IdlePlanObject { Provider registry/collection (used for StepRegistry in this increment; passed through for future steps). .PARAMETER EventSink - Optional external event sink for streaming. Must be an object with a WriteEvent(event) method. + Optional external event sink provided by the host. .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) @@ -27,29 +27,22 @@ function Invoke-IdlePlanObject { [Parameter()] [AllowNull()] - [object] $Providers, + [hashtable] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - # Validate minimal plan shape. Avoid hard typing to keep cross-module compatibility. - $planProps = $Plan.PSObject.Properties.Name - foreach ($required in @('CorrelationId', 'LifecycleEvent', 'Steps')) { - if ($planProps -notcontains $required) { - throw [System.ArgumentException]::new("Plan object must contain property '$required'.", 'Plan') - } - } + $planProps = @($Plan.PSObject.Properties.Name) - # Secure default: treat host-provided extension points as privileged inputs. # The engine rejects ScriptBlocks in the plan and providers to avoid accidental code execution. Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' $events = [System.Collections.Generic.List[object]]::new() - $corr = [string]$Plan.CorrelationId + $corr = [string]$Plan.CorrelationId $actor = if ($planProps -contains 'Actor') { [string]$Plan.Actor } else { $null } # Create the engine-managed event sink object used by both the engine and steps. @@ -58,14 +51,13 @@ function Invoke-IdlePlanObject { # Resolve step types to PowerShell functions via a registry. # This decouples workflow "Type" strings from actual implementation functions. - $registry = Get-IdleStepRegistry -Providers $Providers + $stepRegistry = $null + if ($null -ne $Providers -and $Providers.ContainsKey('StepRegistry')) { + $stepRegistry = $Providers.StepRegistry + } - # Provide a small execution context for steps. - # Steps must not call engine-private functions directly; they only use the context. $context = [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionContext' - CorrelationId = $corr - Actor = $actor Plan = $Plan Providers = $Providers @@ -77,12 +69,12 @@ function Invoke-IdlePlanObject { # Emit run start event. $context.EventSink.WriteEvent('RunStarted', 'Plan execution started.', $null, @{ LifecycleEvent = [string]$Plan.LifecycleEvent - WorkflowName = if ($planProps -contains 'WorkflowName') { + WorkflowName = if ($planProps -contains 'WorkflowName') { [string]$Plan.WorkflowName } else { $null } - StepCount = @($Plan.Steps).Count + StepCount = @($Plan.Steps).Count }) $stepResults = @() @@ -97,26 +89,33 @@ function Invoke-IdlePlanObject { $null } - # Evaluate declarative When condition (data-only). - if ($step.PSObject.Properties.Name -contains 'When' -and $null -ne $step.When) { - $shouldRun = Test-IdleWhenCondition -When $step.When -Context $context - if (-not $shouldRun) { - $stepResults += [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'Skipped' - Error = $null - } - - $context.EventSink.WriteEvent('StepSkipped', "Step '$stepName' skipped (condition not met).", $stepName, @{ - StepType = $stepType - Index = $i - }) - - $i++ - continue + # Step applicability is evaluated during planning (New-IdlePlanObject). + # At execution time we only respect the planned status. + if ($step.PSObject.Properties.Name -contains 'When') { + throw [System.ArgumentException]::new( + "Plan step '$stepName' still contains legacy key 'When'. This has been renamed to 'Condition'. Please rebuild the plan with an updated workflow definition.", + 'Plan' + ) + } + + if ($step.PSObject.Properties.Name -contains 'Status' -and [string]$step.Status -eq 'NotApplicable') { + + # Synthetic step result: the step was not executed because it was deemed not applicable at plan time. + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'NotApplicable' + Error = $null } + + $context.EventSink.WriteEvent('StepNotApplicable', "Step '$stepName' not applicable (condition not met).", $stepName, @{ + StepType = $stepType + Index = $i + }) + + $i++ + continue } $context.EventSink.WriteEvent('StepStarted', "Step '$stepName' started.", $stepName, @{ @@ -127,15 +126,30 @@ function Invoke-IdlePlanObject { try { # Resolve implementation handler for this step type. # Handler must be a function name (string). - $handler = Resolve-IdleStepHandler -StepType $stepType -Registry $registry - if ($null -eq $handler) { - throw [System.InvalidOperationException]::new("Step type '$stepType' is not registered.") + if ($null -eq $stepType -or [string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new("Step '$stepName' is missing a valid Type.", 'Plan') } - # Invoke the step plugin. - $stepResult = & $handler -Context $context -Step $step + if ($null -eq $stepRegistry -or -not ($stepRegistry.ContainsKey($stepType))) { + throw [System.ArgumentException]::new("No step handler registered for type '$stepType'.", 'Providers') + } + + $handlerName = [string]$stepRegistry[$stepType] + if ([string]::IsNullOrWhiteSpace($handlerName)) { + throw [System.ArgumentException]::new("Step handler for type '$stepType' is not a valid function name.", 'Providers') + } - $stepResults += $stepResult + # Execute the step via handler. + $result = & $handlerName -Context $context -Step $step + + # Normalize result shape (minimal contract). + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Status') { [string]$result.Status } else { 'Completed' } + Error = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Error') { $result.Error } else { $null } + } $context.EventSink.WriteEvent('StepCompleted', "Step '$stepName' completed.", $stepName, @{ StepType = $stepType diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index 26dc114b..2d980313 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -33,7 +33,7 @@ AfterAll { Describe 'Invoke-IdlePlan - Condition applicability' { - It 'skips a step when condition is not met' { + It 'does not execute a step when plan marks it as NotApplicable' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ @@ -66,9 +66,9 @@ Describe 'Invoke-IdlePlan - Condition applicability' { $result = Invoke-IdlePlan -Plan $plan -Providers $providers $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Skipped' + $result.Steps[0].Status | Should -Be 'NotApplicable' ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 - ($result.Events | Where-Object Type -eq 'StepSkipped').Count | Should -Be 1 + ($result.Events | Where-Object Type -eq 'StepNotApplicable').Count | Should -Be 1 } It 'runs a step when condition is met' { From ff406dde968f0ec6d691c03614cafa97ae78037b Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 01:13:52 +0100 Subject: [PATCH 21/30] core: rename When to Condition in workflow schema validation --- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index 2d980313..4d82a04a 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -31,11 +31,13 @@ AfterAll { Remove-Item -Path 'Function:\Invoke-IdleConditionTestEmitStep' -ErrorAction SilentlyContinue } -Describe 'Invoke-IdlePlan - Condition applicability' { - - It 'does not execute a step when plan marks it as NotApplicable' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' +InModuleScope IdLE.Core { +# Get-Command Test-IdleConditionSchema -All | Select-Object CommandType, Name, Source, Definition + + Describe 'Invoke-IdlePlan - Condition applicability' { + It 'does not execute a step when plan marks it as NotApplicable' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Condition Demo' LifecycleEvent = 'Joiner' @@ -107,4 +109,5 @@ Describe 'Invoke-IdlePlan - Condition applicability' { $result.Steps[0].Status | Should -Be 'Completed' ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 } -} + } +} \ No newline at end of file From 9d30941d87b29fe567e324c6f4f7d0d670c5c758 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:34:58 +0100 Subject: [PATCH 22/30] fix(core): harden Condition DSL validation and planning --- src/IdLE.Core/Private/Test-IdleCondition.ps1 | 2 +- .../Private/Test-IdleConditionSchema.ps1 | 61 ++++++++++++------- .../Private/Test-IdleStepDefinition.ps1 | 10 +-- .../Private/Test-IdleWorkflowSchema.ps1 | 6 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 2 +- .../Test-IdleWorkflowDefinitionObject.ps1 | 2 +- 6 files changed, 49 insertions(+), 34 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleCondition.ps1 b/src/IdLE.Core/Private/Test-IdleCondition.ps1 index 8d249cda..af6c60c5 100644 --- a/src/IdLE.Core/Private/Test-IdleCondition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleCondition.ps1 @@ -24,7 +24,7 @@ function Test-IdleCondition { # For readability in configuration, a leading "context." prefix is ignored. $schemaErrors = Test-IdleConditionSchema -Condition $Condition -StepName $null - if ($schemaErrors.Count -gt 0) { + if (@($schemaErrors).Count -gt 0) { $msg = "Condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) throw [System.ArgumentException]::new($msg, 'Condition') } diff --git a/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 index fa35a9e2..2fbeae53 100644 --- a/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 @@ -23,18 +23,33 @@ function Test-IdleConditionSchema { # - In = @{ Path = ''; Values = } $errors = [System.Collections.Generic.List[string]]::new() + $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } function Add-IdleConditionError { param( [Parameter(Mandatory)] - [System.Collections.Generic.List[string]] $List, + [ValidateNotNull()] + [object] $List, [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Message ) - $null = $List.Add($Message) + if ($List -is [System.Collections.Generic.List[string]]) { + $null = $List.Add($Message) + return + } + + if ($List -is [System.Collections.ArrayList]) { + $null = $List.Add($Message) + return + } + + throw [System.InvalidOperationException]::new( + ("Add-IdleConditionError expected a mutable list type but got '{0}'." -f $List.GetType().FullName) + ) } function Test-IdleConditionNodeSchema { @@ -53,7 +68,7 @@ function Test-IdleConditionSchema { if (-not ($Node -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) - return $nodeErrors + return ,$nodeErrors } $allowedGroupKeys = @('All', 'Any', 'None') @@ -66,19 +81,19 @@ function Test-IdleConditionSchema { # Enforce: either group OR operator, never both. if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) - return $nodeErrors + return ,$nodeErrors } # Enforce: at least one recognized key. if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) - return $nodeErrors + return ,$nodeErrors } # Enforce: exactly one key at this level (avoids ambiguous evaluation). if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) - return $nodeErrors + return ,$nodeErrors } # Unknown keys are errors. @@ -89,7 +104,7 @@ function Test-IdleConditionSchema { } if ($nodeErrors.Count -gt 0) { - return $nodeErrors + return ,$nodeErrors } # GROUP: All/Any/None must be a non-empty array/list of condition nodes. @@ -100,12 +115,12 @@ function Test-IdleConditionSchema { if ($null -eq $children) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) - return $nodeErrors + return ,$nodeErrors } if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) - return $nodeErrors + return ,$nodeErrors } $i = 0 @@ -122,7 +137,7 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) } - return $nodeErrors + return ,$nodeErrors } # OPERATOR: Exactly one of Equals/NotEquals/Exists/In. @@ -134,7 +149,7 @@ function Test-IdleConditionSchema { 'Equals' { if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Path and Value." -f $opPath) - return $nodeErrors + return ,$nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -151,13 +166,13 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } - return $nodeErrors + return ,$nodeErrors } 'NotEquals' { if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Path and Value." -f $opPath) - return $nodeErrors + return ,$nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -174,7 +189,7 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) } - return $nodeErrors + return ,$nodeErrors } 'Exists' { @@ -185,12 +200,12 @@ function Test-IdleConditionSchema { if ([string]::IsNullOrWhiteSpace([string]$opVal)) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) } - return $nodeErrors + return ,$nodeErrors } if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) - return $nodeErrors + return ,$nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -203,7 +218,7 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) } - return $nodeErrors + return ,$nodeErrors } 'In' { @@ -211,7 +226,7 @@ function Test-IdleConditionSchema { # In = @{ Path = 'context.Identity.Type'; Values = @('Joiner','Mover') } if (-not ($opVal -is [System.Collections.IDictionary])) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Path and Values." -f $opPath) - return $nodeErrors + return ,$nodeErrors } foreach ($k in @($opVal.Keys)) { @@ -226,13 +241,13 @@ function Test-IdleConditionSchema { if (-not $opVal.Contains('Values')) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Values." -f $opPath) - return $nodeErrors + return ,$nodeErrors } $values = $opVal.Values if ($null -eq $values) { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must not be null." -f $opPath) - return $nodeErrors + return ,$nodeErrors } # Values should be list/array (or scalar) but must not be a dictionary (ambiguous). @@ -240,17 +255,17 @@ function Test-IdleConditionSchema { Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must be a list/array (or scalar), not a dictionary." -f $opPath) } - return $nodeErrors + return ,$nodeErrors } } Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) - return $nodeErrors + return ,$nodeErrors } foreach ($e in (Test-IdleConditionNodeSchema -Node $Condition -NodePath ("{0}: Condition" -f $prefix))) { Add-IdleConditionError -List $errors -Message $e } - return $errors + return ,$errors } diff --git a/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 index b902cbea..d8fbc2fa 100644 --- a/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 @@ -40,13 +40,13 @@ function Test-IdleStepDefinition { } } - # Validate When schema (if present) - if ($Step.Contains('When') -and $null -ne $Step['When']) { - if (-not ($Step['When'] -is [hashtable])) { - $errors.Add("Step[$Index] ($name): 'When' must be a hashtable when provided.") + # Validate Condition schema (if present) + if ($Step.Contains('Condition') -and $null -ne $Step['Condition']) { + if (-not ($Step['Condition'] -is [hashtable])) { + $errors.Add("Step[$Index] ($name): 'Condition' must be a hashtable when provided.") } else { - foreach ($e in (Test-IdleWhenConditionSchema -When $Step['When'] -StepName $name)) { + foreach ($e in (Test-IdleConditionSchema -Condition $Step['Condition'] -StepName $name)) { $errors.Add($e) } } diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 1a61ae9b..f1ff9d22 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -42,7 +42,7 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'When', 'With', 'Description') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') foreach ($k in $step.Keys) { if ($allowedStepKeys -notcontains $k) { $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -64,8 +64,8 @@ function Test-IdleWorkflowSchema { # Conditions must be declarative data, never a ScriptBlock/expression. # We only enforce the shape here; semantic validation comes later. - if ($step.ContainsKey('When') -and $null -ne $step.When -and $step.When -isnot [hashtable]) { - $errors.Add("'$stepPath.When' must be a hashtable (declarative condition object).") + 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. diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 4b5a6f33..1866428a 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -88,7 +88,7 @@ function New-IdlePlanObject { $status = 'Planned' if ($null -ne $condition) { $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName ([string]$s.Name) - if ($schemaErrors.Count -gt 0) { + if (@($schemaErrors).Count -gt 0) { throw [System.ArgumentException]::new( ("Invalid Condition on step '{0}': {1}" -f [string]$s.Name, ([string]::Join(' ', @($schemaErrors)))), 'Workflow' diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 index 96085757..7449cfd7 100644 --- a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -58,7 +58,7 @@ function Test-IdleWorkflowDefinitionObject { } } - # 4b) Validate step definitions (Name/Type/When/With + data-only). + # 4b) Validate step definitions (Name/Type/Condition/With + data-only). $idx = 0 foreach ($s in @($workflow.Steps)) { $stepErrors = Test-IdleStepDefinition -Step $s -Index $idx From e311834572862957fd1ad7adfff700ff8f96ee32 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:35:16 +0100 Subject: [PATCH 23/30] test(core): stabilize Condition DSL tests --- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 8 +++----- tests/Test-IdleCondition.Tests.ps1 | 14 ++++++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index 4d82a04a..c0f9ccfd 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -31,9 +31,7 @@ AfterAll { Remove-Item -Path 'Function:\Invoke-IdleConditionTestEmitStep' -ErrorAction SilentlyContinue } -InModuleScope IdLE.Core { -# Get-Command Test-IdleConditionSchema -All | Select-Object CommandType, Name, Source, Definition - +InModuleScope IdLE.Core { Describe 'Invoke-IdlePlan - Condition applicability' { It 'does not execute a step when plan marks it as NotApplicable' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' @@ -69,8 +67,8 @@ InModuleScope IdLE.Core { $result.Status | Should -Be 'Completed' $result.Steps[0].Status | Should -Be 'NotApplicable' - ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 - ($result.Events | Where-Object Type -eq 'StepNotApplicable').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'StepNotApplicable').Count | Should -Be 1 } It 'runs a step when condition is met' { diff --git a/tests/Test-IdleCondition.Tests.ps1 b/tests/Test-IdleCondition.Tests.ps1 index 36fa8f65..74f76afb 100644 --- a/tests/Test-IdleCondition.Tests.ps1 +++ b/tests/Test-IdleCondition.Tests.ps1 @@ -7,6 +7,12 @@ Describe 'Condition DSL (schema + evaluator)' { InModuleScope 'IdLE.Core' { + BeforeAll { + # Guarding to ensure the functions are available. + Get-Command Test-IdleConditionSchema -ErrorAction Stop | Out-Null + Get-Command Test-IdleCondition -ErrorAction Stop | Out-Null + } + Describe 'Test-IdleConditionSchema' { It 'accepts an Equals operator with Path + Value' { @@ -18,7 +24,7 @@ Describe 'Condition DSL (schema + evaluator)' { } $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' - $errors | Should -BeEmpty + $errors.Count | Should -Be 0 } It 'accepts a nested All group with multiple conditions' { @@ -30,7 +36,7 @@ Describe 'Condition DSL (schema + evaluator)' { } $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' - $errors | Should -BeEmpty + $errors.Count | Should -Be 0 } It 'accepts Exists as short form string path' { @@ -39,7 +45,7 @@ Describe 'Condition DSL (schema + evaluator)' { } $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' - $errors | Should -BeEmpty + $errors.Count | Should -Be 0 } It 'accepts In operator with Values as array' { @@ -51,7 +57,7 @@ Describe 'Condition DSL (schema + evaluator)' { } $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' - $errors | Should -BeEmpty + $errors.Count | Should -Be 0 } It 'rejects unknown keys' { From 26074f6cdc52de033f657ad810a2b92c805ac7ac Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:35:23 +0100 Subject: [PATCH 24/30] docs(examples): migrate workflow samples from When to Condition --- docs/usage/workflows.md | 4 +-- examples/workflows/joiner-with-condition.psd1 | 32 +++++++++++++++++++ examples/workflows/joiner-with-when.psd1 | 28 ---------------- 3 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 examples/workflows/joiner-with-condition.psd1 delete mode 100644 examples/workflows/joiner-with-when.psd1 diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index 4df3a317..992a8682 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -45,12 +45,12 @@ The host maps step types to step implementations via a step registry. ## Conditional steps -Steps can be skipped using declarative `When` conditions. +Steps can be skipped using declarative `Condition` key. Example: ```powershell -When = @{ +Condition = @{ Path = 'Plan.LifecycleEvent' Equals = 'Joiner' } diff --git a/examples/workflows/joiner-with-condition.psd1 b/examples/workflows/joiner-with-condition.psd1 new file mode 100644 index 00000000..7993ffb0 --- /dev/null +++ b/examples/workflows/joiner-with-condition.psd1 @@ -0,0 +1,32 @@ +@{ + Name = 'Joiner - Condition Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'EmitOnlyForJoiner' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + With = @{ + Message = 'This step runs only if Plan.LifecycleEvent == Joiner.' + } + } + @{ + Name = 'SkipForJoiner' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + With = @{ + Message = 'You should never see this in a Joiner run.' + } + } + ) +} diff --git a/examples/workflows/joiner-with-when.psd1 b/examples/workflows/joiner-with-when.psd1 deleted file mode 100644 index 48e88090..00000000 --- a/examples/workflows/joiner-with-when.psd1 +++ /dev/null @@ -1,28 +0,0 @@ -@{ - Name = 'Joiner - When Demo' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'EmitOnlyForJoiner' - Type = 'IdLE.Step.EmitEvent' - When = @{ - Path = 'Plan.LifecycleEvent' - Equals = 'Joiner' - } - With = @{ - Message = 'This step runs only when Plan.LifecycleEvent == Joiner.' - } - } - @{ - Name = 'SkipForJoiner' - Type = 'IdLE.Step.EmitEvent' - When = @{ - Path = 'Plan.LifecycleEvent' - Equals = 'Leaver' - } - With = @{ - Message = 'You should never see this in a Joiner run.' - } - } - ) -} From 5959c9561bbeebfac3d3c3d5260afa4fb84c04d0 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:46:22 +0100 Subject: [PATCH 25/30] docs: updated Cmldlet reference --- docs/reference/cmdlets/Invoke-IdlePlan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/cmdlets/Invoke-IdlePlan.md b/docs/reference/cmdlets/Invoke-IdlePlan.md index 71da2505..2f002d73 100644 --- a/docs/reference/cmdlets/Invoke-IdlePlan.md +++ b/docs/reference/cmdlets/Invoke-IdlePlan.md @@ -13,7 +13,7 @@ Executes an IdLE plan. ## SYNTAX ``` -Invoke-IdlePlan [-Plan] [[-Providers] ] [[-EventSink] ] +Invoke-IdlePlan [-Plan] [[-Providers] ] [[-EventSink] ] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` @@ -41,7 +41,7 @@ Aliases: Required: True Position: 1 Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` @@ -49,7 +49,7 @@ Accept wildcard characters: False Provider registry/collection passed through to execution. ```yaml -Type: Object +Type: Hashtable Parameter Sets: (All) Aliases: @@ -129,7 +129,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS -### System.Object +### PSCustomObject (PSTypeName: IdLE.ExecutionResult) ## NOTES ## RELATED LINKS From 21c4c67e36948c390aa9d344f0eea69b55cfa459 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:57:19 +0100 Subject: [PATCH 26/30] =?UTF-8?q?=C3=A4ndern=20auf=20BeforeDiscovery=20f?= =?UTF-8?q?=C3=BCr=20Import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 8 +++++--- tests/Test-IdleCondition.Tests.ps1 | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index c0f9ccfd..dffff6ed 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -1,7 +1,9 @@ -BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force +BeforeDiscovery { + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') + Import-Module (Join-Path $repoRoot 'src/IdLE/IdLE.psd1') -Force -ErrorAction Stop +} +BeforeAll { function global:Invoke-IdleConditionTestEmitStep { [CmdletBinding()] param( diff --git a/tests/Test-IdleCondition.Tests.ps1 b/tests/Test-IdleCondition.Tests.ps1 index 74f76afb..febb5a81 100644 --- a/tests/Test-IdleCondition.Tests.ps1 +++ b/tests/Test-IdleCondition.Tests.ps1 @@ -1,6 +1,6 @@ -BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force +BeforeDiscovery { + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') + Import-Module (Join-Path $repoRoot 'src/IdLE/IdLE.psd1') -Force -ErrorAction Stop } Describe 'Condition DSL (schema + evaluator)' { From 9b3358fb501771cb94c87fb849495710a53a9415 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 03:05:26 +0100 Subject: [PATCH 27/30] test: add centralized module import helpers --- tests/_testHelpers.ps1 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index d09bd9d0..aa9beb9f 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -8,6 +8,22 @@ function Get-RepoRootPath { return (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path } +function Get-IdleModuleManifestPath { + [CmdletBinding()] + param() + + $repoRoot = Get-RepoRootPath + return (Resolve-Path -Path (Join-Path $repoRoot 'src/IdLE/IdLE.psd1')).Path +} + +function Import-IdleTestModule { + [CmdletBinding()] + param() + + $manifestPath = Get-IdleModuleManifestPath + Import-Module -Name $manifestPath -Force -ErrorAction Stop +} + function Get-ModuleManifestPaths { [CmdletBinding()] param() @@ -21,3 +37,4 @@ function Get-ModuleManifestPaths { Where-Object { $_.Directory.Parent -and $_.Directory.Parent.Name -eq 'src' } | Select-Object -ExpandProperty FullName } + From 8cbc47048d36bb3bd3066bcfd1c09e7e5349ec70 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 03:05:35 +0100 Subject: [PATCH 28/30] test: normalize module import across tests --- tests/Invoke-IdlePlan.Condition.Tests.ps1 | 4 ++-- tests/Invoke-IdlePlan.Tests.ps1 | 4 ++-- tests/ModuleManifests.Tests.ps1 | 3 ++- tests/ModuleSurface.Tests.ps1 | 3 +++ tests/New-IdleLifecycleRequest.Tests.ps1 | 4 ++-- tests/New-IdlePlan.Tests.ps1 | 4 ++-- tests/PublicApi.Tests.ps1 | 9 ++------- tests/Test-IdleCondition.Tests.ps1 | 4 ++-- tests/Test-IdleWorkflow.Tests.ps1 | 4 ++-- tests/WorkflowSamples.Tests.ps1 | 11 +++-------- 10 files changed, 22 insertions(+), 28 deletions(-) diff --git a/tests/Invoke-IdlePlan.Condition.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 index dffff6ed..d2a08229 100644 --- a/tests/Invoke-IdlePlan.Condition.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -1,6 +1,6 @@ BeforeDiscovery { - $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') - Import-Module (Join-Path $repoRoot 'src/IdLE/IdLE.psd1') -Force -ErrorAction Stop + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } BeforeAll { diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 57b3c832..09b4ae1b 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule # The engine invokes step handlers by function name (string) inside module scope. # Therefore, test handler functions must be visible to the module (global scope). diff --git a/tests/ModuleManifests.Tests.ps1 b/tests/ModuleManifests.Tests.ps1 index 4e28b9cf..029253ec 100644 --- a/tests/ModuleManifests.Tests.ps1 +++ b/tests/ModuleManifests.Tests.ps1 @@ -1,7 +1,8 @@ Set-StrictMode -Version Latest BeforeAll { - . (Join-Path -Path $PSScriptRoot -ChildPath '_testHelpers.ps1') + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'Module manifests' { diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index da69f1eb..41734efc 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -4,6 +4,9 @@ BeforeAll { $corePsd1 = Join-Path $repoRoot 'src\IdLE.Core\IdLE.Core.psd1' $stepsPsd1 = Join-Path $repoRoot 'src\IdLE.Steps.Common\IdLE.Steps.Common.psd1' $providerMockPsd1 = Join-Path $repoRoot 'src\IdLE.Provider.Mock\IdLE.Provider.Mock.psd1' + + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'Module manifests and public surface' { diff --git a/tests/New-IdleLifecycleRequest.Tests.ps1 b/tests/New-IdleLifecycleRequest.Tests.ps1 index ced50f9f..06901ef0 100644 --- a/tests/New-IdleLifecycleRequest.Tests.ps1 +++ b/tests/New-IdleLifecycleRequest.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'New-IdleLifecycleRequest' { diff --git a/tests/New-IdlePlan.Tests.ps1 b/tests/New-IdlePlan.Tests.ps1 index c487e872..c833b980 100644 --- a/tests/New-IdlePlan.Tests.ps1 +++ b/tests/New-IdlePlan.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'New-IdlePlan' { diff --git a/tests/PublicApi.Tests.ps1 b/tests/PublicApi.Tests.ps1 index 010b8fda..be225b1f 100644 --- a/tests/PublicApi.Tests.ps1 +++ b/tests/PublicApi.Tests.ps1 @@ -1,13 +1,8 @@ Set-StrictMode -Version Latest BeforeAll { - . (Join-Path -Path $PSScriptRoot -ChildPath '_testHelpers.ps1') - - $repoRoot = Get-RepoRootPath - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - - Remove-Module -Name IdLE, IdLE.Core -Force -ErrorAction SilentlyContinue - Import-Module -Name $idleManifest -Force -ErrorAction Stop + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'IdLE public API surface' { diff --git a/tests/Test-IdleCondition.Tests.ps1 b/tests/Test-IdleCondition.Tests.ps1 index febb5a81..86dfd1d7 100644 --- a/tests/Test-IdleCondition.Tests.ps1 +++ b/tests/Test-IdleCondition.Tests.ps1 @@ -1,6 +1,6 @@ BeforeDiscovery { - $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') - Import-Module (Join-Path $repoRoot 'src/IdLE/IdLE.psd1') -Force -ErrorAction Stop + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'Condition DSL (schema + evaluator)' { diff --git a/tests/Test-IdleWorkflow.Tests.ps1 b/tests/Test-IdleWorkflow.Tests.ps1 index 33f30faf..6ddc7585 100644 --- a/tests/Test-IdleWorkflow.Tests.ps1 +++ b/tests/Test-IdleWorkflow.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'Test-IdleWorkflow' { diff --git a/tests/WorkflowSamples.Tests.ps1 b/tests/WorkflowSamples.Tests.ps1 index 230b3f27..6431712c 100644 --- a/tests/WorkflowSamples.Tests.ps1 +++ b/tests/WorkflowSamples.Tests.ps1 @@ -1,15 +1,10 @@ Set-StrictMode -Version Latest BeforeAll { - . (Join-Path -Path $PSScriptRoot -ChildPath '_testHelpers.ps1') + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule - $repoRoot = Get-RepoRootPath - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - - Remove-Module -Name IdLE, IdLE.Core -Force -ErrorAction SilentlyContinue - Import-Module -Name $idleManifest -Force -ErrorAction Stop - - $workflowsPath = Join-Path -Path $repoRoot -ChildPath 'examples/workflows' + $workflowsPath = Join-Path -Path (Get-RepoRootPath) -ChildPath 'examples/workflows' } Describe 'Example workflows' { From 1922a48358af43aa22349a1966fb6a0d440a8bcc Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:21:47 +0100 Subject: [PATCH 29/30] core: fix resolve step handlers via Get-IdleStepRegistry --- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index aff0ac80..10083add 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -50,11 +50,12 @@ function Invoke-IdlePlanObject { $engineEventSink = New-IdleEventSink -CorrelationId $corr -Actor $actor -ExternalEventSink $EventSink -EventBuffer $events # Resolve step types to PowerShell functions via a registry. - # This decouples workflow "Type" strings from actual implementation functions. - $stepRegistry = $null - if ($null -ne $Providers -and $Providers.ContainsKey('StepRegistry')) { - $stepRegistry = $Providers.StepRegistry - } + # + # IMPORTANT: + # - The host MAY provide a StepRegistry, but it is optional. + # - Built-in steps must remain discoverable without requiring the host to wire a registry. + # - Get-IdleStepRegistry merges the host registry (if provided) with built-in handlers (if available). + $stepRegistry = Get-IdleStepRegistry -Providers $Providers $context = [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionContext' From f907b962db9a346573c65fa99479aa42ba9724b7 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:07:35 +0100 Subject: [PATCH 30/30] test(core): prevent regression for built-in step discovery without StepRegistry --- tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 diff --git a/tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 b/tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 new file mode 100644 index 00000000..3962ca03 --- /dev/null +++ b/tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 @@ -0,0 +1,46 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # The meta module (IdLE) does not automatically import optional step packs. + # For this test we explicitly load the built-in steps module so that + # Get-IdleStepRegistry can discover the handler via Get-Command. + $repoRoot = Get-RepoRootPath + $stepsManifestPath = Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Steps.Common/IdLE.Steps.Common.psd1' + Import-Module -Name $stepsManifestPath -Force -ErrorAction Stop +} + +AfterAll { + # Cleanup to avoid influencing other tests that might rely on a clean module state. + Remove-Module -Name 'IdLE.Steps.Common' -ErrorAction SilentlyContinue +} + +Describe 'Invoke-IdlePlan - StepRegistry' { + It 'executes built-in steps without a host-provided StepRegistry' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'emit-built-in.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Emit'; Type = 'IdLE.Step.EmitEvent'; With = @{ Message = 'Hello' } } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + # Intentionally no Providers.StepRegistry here. + $providers = @{} + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + @($result.Steps).Count | Should -Be 1 + $result.Steps[0].Status | Should -Be 'Completed' + + # The built-in EmitEvent step emits a Custom event. + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } +}