From 2e72ded5eee87ffd838a26883950290b3eb8db49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:23:13 +0000 Subject: [PATCH 1/8] Initial plan From d9883da0b84096a4ebdc2df74c0f824f8aadbd59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:27:08 +0000 Subject: [PATCH 2/8] Implement step pack catalog ownership and discovery logic - Update Resolve-IdleStepMetadataCatalog to discover loaded IdLE.Steps.* modules - Implement deterministic merge with duplicate detection - Change host metadata to supplement-only (no overrides) - Add Get-IdleStepMetadataCatalog to IdLE.Steps.DirectorySync - Move TriggerDirectorySync metadata from Common to DirectorySync - Update error messages for clearer distinction Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Resolve-IdleStepMetadataCatalog.ps1 | 224 ++++++++++++------ src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 8 +- .../Public/Get-IdleStepMetadataCatalog.ps1 | 6 - .../IdLE.Steps.DirectorySync.psd1 | 1 + .../IdLE.Steps.DirectorySync.psm1 | 1 + .../Public/Get-IdleStepMetadataCatalog.ps1 | 35 +++ 6 files changed, 191 insertions(+), 84 deletions(-) create mode 100644 src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 37b20ffc..bcd348f7 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -19,36 +19,6 @@ function Resolve-IdleStepMetadataCatalog { $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - # Helper: Resolve a function from a module without requiring global command exports. - function Resolve-IdleModuleFunction { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $FunctionName, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $ModuleName - ) - - # 1) Module-scoped discovery (prefer the specified module to avoid name shadowing) - $module = Get-Module -Name $ModuleName -All | Select-Object -First 1 - if ($null -ne $module -and - $null -ne $module.ExportedCommands -and - $module.ExportedCommands.ContainsKey($FunctionName)) { - return $module.ExportedCommands[$FunctionName] - } - - # 2) Global discovery (fallback; supports hosts that import modules globally) - $cmd = Get-Command -Name $FunctionName -ErrorAction SilentlyContinue - if ($null -ne $cmd) { - return $cmd - } - - return $null - } - # Helper: Validate a single capability identifier format. function Test-IdleCapabilityIdentifier { [CmdletBinding()] @@ -132,8 +102,53 @@ function Resolve-IdleStepMetadataCatalog { ) } - # Helper: Validate and merge metadata from a hashtable source. - function Merge-IdleStepMetadata { + # Helper: Get host-provided StepMetadata if available. + function Get-IdleHostStepMetadata { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) + + if ($null -eq $Providers) { + return $null + } + + if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepMetadata')) { + return $Providers['StepMetadata'] + } + + if ($Providers.PSObject.Properties.Name -contains 'StepMetadata') { + return $Providers.StepMetadata + } + + return $null + } + + # Helper: Discover loaded step packs exporting Get-IdleStepMetadataCatalog. + function Get-IdleStepPackModules { + [CmdletBinding()] + param() + + $loadedModules = Get-Module -Name 'IdLE.Steps.*' + if ($null -eq $loadedModules) { + return @() + } + + $stepPackModules = @() + foreach ($m in @($loadedModules)) { + if ($null -ne $m.ExportedCommands -and $m.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog')) { + $stepPackModules += $m + } + } + + # Sort by module name for deterministic order + return @($stepPackModules | Sort-Object -Property Name) + } + + # Helper: Merge step pack catalog with duplicate detection. + function Merge-IdleStepPackCatalog { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -144,25 +159,27 @@ function Resolve-IdleStepMetadataCatalog { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $SourceName + [string] $SourceModuleName, + + [Parameter(Mandatory)] + [hashtable] $StepTypeOwners ) foreach ($key in $Source.Keys) { if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { - throw [System.ArgumentException]::new("$SourceName contains an empty step type key.", 'Providers') + throw [System.ArgumentException]::new("$SourceModuleName contains an empty step type key.", 'Providers') } $value = $Source[$key] if ($value -isnot [hashtable]) { throw [System.ArgumentException]::new( - "$SourceName entry for step type '$key' must be a hashtable (metadata object).", + "$SourceModuleName entry for step type '$key' must be a hashtable (metadata object).", 'Providers' ) } # Validate metadata shape (data-only, no ScriptBlocks). - # Recursively check for ScriptBlocks in the entire metadata tree. function Test-IdleMetadataForScriptBlocks { param( [Parameter(Mandatory)] @@ -179,7 +196,7 @@ function Resolve-IdleStepMetadataCatalog { if ($Value -is [scriptblock]) { throw [System.ArgumentException]::new( - "$SourceName entry for step type '$key' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", + "$SourceModuleName entry for step type '$key' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", 'Providers' ) } @@ -205,54 +222,40 @@ function Resolve-IdleStepMetadataCatalog { Test-IdleMetadataForScriptBlocks -Value $metaValue -Path $metaKey if ($metaKey -eq 'RequiredCapabilities') { - Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceName + Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceModuleName } } - # Merge (host metadata overrides built-in). + # Check for duplicates across step packs + if ($StepTypeOwners.ContainsKey([string]$key)) { + $existingOwner = $StepTypeOwners[[string]$key] + throw [System.InvalidOperationException]::new( + ("DuplicateStepTypeMetadata: Step type '{0}' is defined in both '{1}' and '{2}'. " + + "Step packs must own unique step types." -f $key, $existingOwner, $SourceModuleName) + ) + } + + # Register ownership and add to catalog + $StepTypeOwners[[string]$key] = $SourceModuleName $Target[[string]$key] = $value } } - # Helper: Get host-provided StepMetadata if available. - function Get-IdleHostStepMetadata { - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Providers - ) - - if ($null -eq $Providers) { - return $null - } + # 1) Discover and merge step pack catalogs (deterministic order). + $stepTypeOwners = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + $stepPackModules = Get-IdleStepPackModules - if ($Providers -is [hashtable] -and $Providers.ContainsKey('StepMetadata')) { - return $Providers['StepMetadata'] - } - - if ($Providers.PSObject.Properties.Name -contains 'StepMetadata') { - return $Providers.StepMetadata - } - - return $null - } - - # 1) Register built-in step metadata if available. - $builtInFunction = Resolve-IdleModuleFunction -FunctionName 'Get-IdleStepMetadataCatalog' -ModuleName 'IdLE.Steps.Common' - - if ($null -ne $builtInFunction) { - $functionModule = $builtInFunction.ModuleName ?? $builtInFunction.Source - - if ($functionModule -eq 'IdLE.Steps.Common') { - $builtInMetadata = & $builtInFunction - if ($null -ne $builtInMetadata -and $builtInMetadata -is [hashtable]) { - Merge-IdleStepMetadata -Target $catalog -Source $builtInMetadata -SourceName 'Built-in StepMetadata' + foreach ($module in $stepPackModules) { + $catalogFunction = $module.ExportedCommands['Get-IdleStepMetadataCatalog'] + if ($null -ne $catalogFunction) { + $stepPackCatalog = & $catalogFunction + if ($null -ne $stepPackCatalog -and $stepPackCatalog -is [hashtable]) { + Merge-IdleStepPackCatalog -Target $catalog -Source $stepPackCatalog -SourceModuleName $module.Name -StepTypeOwners $stepTypeOwners } } } - # 2) Merge host-provided StepMetadata (optional) - this overrides built-in. + # 2) Apply host-provided StepMetadata as supplement-only (no overrides). $hostMetadata = Get-IdleHostStepMetadata -Providers $Providers if ($null -ne $hostMetadata) { @@ -260,7 +263,80 @@ function Resolve-IdleStepMetadataCatalog { throw [System.ArgumentException]::new('Providers.StepMetadata must be a hashtable that maps Step.Type to a metadata object (hashtable).', 'Providers') } - Merge-IdleStepMetadata -Target $catalog -Source $hostMetadata -SourceName 'Providers.StepMetadata' + foreach ($key in $hostMetadata.Keys) { + if ($null -eq $key -or [string]::IsNullOrWhiteSpace([string]$key)) { + throw [System.ArgumentException]::new('Providers.StepMetadata contains an empty step type key.', 'Providers') + } + + # Check if this step type already exists in step pack catalog (no override allowed) + if ($catalog.ContainsKey([string]$key)) { + $existingOwner = $stepTypeOwners[[string]$key] + throw [System.InvalidOperationException]::new( + ("DuplicateStepTypeMetadata: Step type '{0}' is already defined in step pack '{1}'. " + + "Host metadata (Providers.StepMetadata) can only supplement with new step types, not override existing ones." -f $key, $existingOwner) + ) + } + + $value = $hostMetadata[$key] + + if ($value -isnot [hashtable]) { + throw [System.ArgumentException]::new( + "Providers.StepMetadata entry for step type '$key' must be a hashtable (metadata object).", + 'Providers' + ) + } + + # Validate metadata shape (data-only, no ScriptBlocks). + function Test-IdleMetadataForScriptBlocks { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $Path + ) + + if ($null -eq $Value) { + return + } + + if ($Value -is [scriptblock]) { + throw [System.ArgumentException]::new( + "Providers.StepMetadata entry for step type '$key' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", + 'Providers' + ) + } + + if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary]) { + foreach ($k in $Value.Keys) { + Test-IdleMetadataForScriptBlocks -Value $Value[$k] -Path "$Path.$k" + } + } + elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $index = 0 + foreach ($item in $Value) { + Test-IdleMetadataForScriptBlocks -Value $item -Path "$Path[$index]" + $index++ + } + } + } + + foreach ($metaKey in $value.Keys) { + $metaValue = $value[$metaKey] + + # Recursively validate no ScriptBlocks anywhere in metadata + Test-IdleMetadataForScriptBlocks -Value $metaValue -Path $metaKey + + if ($metaKey -eq 'RequiredCapabilities') { + Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName 'Providers.StepMetadata' + } + } + + # Add host supplement + $catalog[[string]$key] = $value + $stepTypeOwners[[string]$key] = 'Host' + } } return $catalog diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 67f72b86..274e3193 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -501,10 +501,10 @@ function New-IdlePlanObject { } else { # Workflow references a Step.Type for which no StepMetadata entry is available - fail fast. - throw [System.ArgumentException]::new( - ("Workflow step '{0}' references Step.Type '{1}' which has no StepMetadata entry. " + - "Host must provide Providers.StepMetadata['{1}'] = @{{ RequiredCapabilities = ... }}." -f $stepName, $stepType), - 'Providers' + throw [System.InvalidOperationException]::new( + ("MissingStepTypeMetadata: Workflow step '{0}' references step type '{1}' which has no metadata entry. " + + "To resolve this: (1) Import/load the step pack module (IdLE.Steps.*) that provides metadata for '{1}' via Get-IdleStepMetadataCatalog, OR " + + "(2) For host-defined/custom step types only, provide Providers.StepMetadata['{1}'] = @{{ RequiredCapabilities = @(...) }}." -f $stepName, $stepType) ) } diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index c449c769..2f266676 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -63,11 +63,5 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') } - # IdLE.Step.TriggerDirectorySync - requires trigger and status capabilities - # Note: Even when With.Wait = $false, we advertise Status capability to keep planning deterministic - $catalog['IdLE.Step.TriggerDirectorySync'] = @{ - RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') - } - return $catalog } diff --git a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 index 910e49d1..b2f4298b 100644 --- a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 +++ b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1 @@ -12,6 +12,7 @@ ) FunctionsToExport = @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepTriggerDirectorySync' ) diff --git a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 index cb677b8a..1e9e2b68 100644 --- a/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 +++ b/src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psm1 @@ -25,5 +25,6 @@ if (Test-Path -Path $PublicPath) { } Export-ModuleMember -Function @( + 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepTriggerDirectorySync' ) diff --git a/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..df754fcf --- /dev/null +++ b/src/IdLE.Steps.DirectorySync/Public/Get-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,35 @@ +function Get-IdleStepMetadataCatalog { + <# + .SYNOPSIS + Returns metadata for DirectorySync step types. + + .DESCRIPTION + This function provides a metadata catalog mapping Step.Type to metadata objects + for directory sync step types owned by this step pack. + + Each metadata object contains RequiredCapabilities (array of capability identifiers). + + The metadata is used during plan building to derive required provider capabilities + for each step, removing the need to declare RequiresCapabilities in workflow definitions. + + .OUTPUTS + Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). + + .EXAMPLE + $metadata = Get-IdleStepMetadataCatalog + $metadata['IdLE.Step.TriggerDirectorySync'].RequiredCapabilities + # Returns: @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + #> + [CmdletBinding()] + param() + + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # IdLE.Step.TriggerDirectorySync - requires trigger and status capabilities + # Note: Even when With.Wait = $false, we advertise Status capability to keep planning deterministic + $catalog['IdLE.Step.TriggerDirectorySync'] = @{ + RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } + + return $catalog +} From 838f073173a04e6cd0324373cd33481064dfbad3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:30:15 +0000 Subject: [PATCH 3/8] Add comprehensive tests for step metadata catalog ownership - Add tests for discovery, merge, duplicates, and supplements - Update existing tests to reflect breaking change (no overrides) - Create test fixture for DirectorySync workflow - All tests passing Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Resolve-IdleStepMetadataCatalog.ps1 | 14 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 9 +- tests/New-IdlePlan.Capabilities.Tests.ps1 | 19 +- .../Resolve-IdleStepMetadataCatalog.Tests.ps1 | 259 ++++++++++++++++++ .../workflows/joiner-with-dirsync.psd1 | 16 ++ 5 files changed, 296 insertions(+), 21 deletions(-) create mode 100644 tests/Resolve-IdleStepMetadataCatalog.Tests.ps1 create mode 100644 tests/fixtures/workflows/joiner-with-dirsync.psd1 diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index bcd348f7..e758b097 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -229,10 +229,9 @@ function Resolve-IdleStepMetadataCatalog { # Check for duplicates across step packs if ($StepTypeOwners.ContainsKey([string]$key)) { $existingOwner = $StepTypeOwners[[string]$key] - throw [System.InvalidOperationException]::new( - ("DuplicateStepTypeMetadata: Step type '{0}' is defined in both '{1}' and '{2}'. " + - "Step packs must own unique step types." -f $key, $existingOwner, $SourceModuleName) - ) + $errorMessage = "DuplicateStepTypeMetadata: Step type '$key' is defined in both '$existingOwner' and '$SourceModuleName'. " + + "Step packs must own unique step types." + throw [System.InvalidOperationException]::new($errorMessage) } # Register ownership and add to catalog @@ -271,10 +270,9 @@ function Resolve-IdleStepMetadataCatalog { # Check if this step type already exists in step pack catalog (no override allowed) if ($catalog.ContainsKey([string]$key)) { $existingOwner = $stepTypeOwners[[string]$key] - throw [System.InvalidOperationException]::new( - ("DuplicateStepTypeMetadata: Step type '{0}' is already defined in step pack '{1}'. " + - "Host metadata (Providers.StepMetadata) can only supplement with new step types, not override existing ones." -f $key, $existingOwner) - ) + $errorMessage = "DuplicateStepTypeMetadata: Step type '$key' is already defined in step pack '$existingOwner'. " + + "Host metadata (Providers.StepMetadata) can only supplement with new step types, not override existing ones." + throw [System.InvalidOperationException]::new($errorMessage) } $value = $hostMetadata[$key] diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 274e3193..5dbb79dc 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -501,11 +501,10 @@ function New-IdlePlanObject { } else { # Workflow references a Step.Type for which no StepMetadata entry is available - fail fast. - throw [System.InvalidOperationException]::new( - ("MissingStepTypeMetadata: Workflow step '{0}' references step type '{1}' which has no metadata entry. " + - "To resolve this: (1) Import/load the step pack module (IdLE.Steps.*) that provides metadata for '{1}' via Get-IdleStepMetadataCatalog, OR " + - "(2) For host-defined/custom step types only, provide Providers.StepMetadata['{1}'] = @{{ RequiredCapabilities = @(...) }}." -f $stepName, $stepType) - ) + $errorMessage = "MissingStepTypeMetadata: Workflow step '$stepName' references step type '$stepType' which has no metadata entry. " + + "To resolve this: (1) Import/load the step pack module (IdLE.Steps.*) that provides metadata for '$stepType' via Get-IdleStepMetadataCatalog, OR " + + "(2) For host-defined/custom step types only, provide Providers.StepMetadata['$stepType'] = @{ RequiredCapabilities = @(...) }." + throw [System.InvalidOperationException]::new($errorMessage) } $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index cfea0213..e28ac11f 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -6,7 +6,7 @@ BeforeAll { Describe 'New-IdlePlan - required provider capabilities' { - It 'fails fast when a step type has no metadata entry' { + It 'fails fast when a step type has no metadata entry (MissingStepTypeMetadata)' { $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -23,7 +23,7 @@ Describe 'New-IdlePlan - required provider capabilities' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'no StepMetadata entry' + $_.Exception.Message | Should -Match 'MissingStepTypeMetadata' $_.Exception.Message | Should -Match 'Custom.Step.Unknown' } } @@ -65,7 +65,7 @@ Describe 'New-IdlePlan - required provider capabilities' { } } - It 'allows host metadata to override built-in metadata' { + It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-override.psd1' $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' @@ -84,11 +84,14 @@ Describe 'New-IdlePlan - required provider capabilities' { } } - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan | Should -Not -BeNullOrEmpty - $plan.Steps.Count | Should -Be 1 - $plan.Steps[0].RequiresCapabilities | Should -Be @('Custom.Capability.Override') + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + } } It 'validates OnFailureSteps capabilities from metadata' { diff --git a/tests/Resolve-IdleStepMetadataCatalog.Tests.ps1 b/tests/Resolve-IdleStepMetadataCatalog.Tests.ps1 new file mode 100644 index 00000000..9c6cff7e --- /dev/null +++ b/tests/Resolve-IdleStepMetadataCatalog.Tests.ps1 @@ -0,0 +1,259 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + $fixturesPath = Join-Path $PSScriptRoot 'fixtures/workflows' +} + +Describe 'Resolve-IdleStepMetadataCatalog - step pack catalog ownership' { + + It 'discovers loaded step packs exporting Get-IdleStepMetadataCatalog' { + # Both IdLE.Steps.Common and IdLE.Steps.DirectorySync should be loaded + $commonModule = Get-Module -Name 'IdLE.Steps.Common' + $commonModule | Should -Not -BeNullOrEmpty + $commonModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue + + $dirSyncModule = Get-Module -Name 'IdLE.Steps.DirectorySync' + $dirSyncModule | Should -Not -BeNullOrEmpty + $dirSyncModule.ExportedCommands.ContainsKey('Get-IdleStepMetadataCatalog') | Should -BeTrue + + # Create a minimal workflow to trigger catalog resolution + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + # This will internally call Resolve-IdleStepMetadataCatalog + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Verify the catalog was used - the step should have capabilities from metadata + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + } + + It 'merges catalogs from multiple step packs deterministically' { + # Create workflows that use steps from both Common and DirectorySync + $wfPathDirSync = Join-Path -Path $fixturesPath -ChildPath 'joiner-with-dirsync.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } -Force + + $providers = @{ + DirectorySync = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPathDirSync -Request $req -Providers $providers + + # DirectorySync step should have correct capabilities + $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' + } + + It 'allows host to supplement with new step types not in step packs' { + # Create a workflow with a custom step type + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'CustomProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Test') + } -Force + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + StepMetadata = @{ + 'Custom.Step.Unknown' = @{ + RequiredCapabilities = @('Custom.Capability.Test') + } + } + CustomProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Custom step should have host-provided capabilities + $plan.Steps[0].RequiresCapabilities | Should -Contain 'Custom.Capability.Test' + } + + It 'rejects host override attempt of step pack metadata (DuplicateStepTypeMetadata)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Custom.Capability.Override') + } -Force + + $providers = @{ + IdentityProvider = $provider + StepMetadata = @{ + 'IdLE.Step.DisableIdentity' = @{ + RequiredCapabilities = @('Custom.Capability.Override') + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'DuplicateStepTypeMetadata' + $_.Exception.Message | Should -Match 'IdLE.Step.DisableIdentity' + $_.Exception.Message | Should -Match 'IdLE.Steps.Common' + $_.Exception.Message | Should -Match 'supplement' + } + } + + It 'validates metadata does not contain ScriptBlocks (host supplement)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + StepMetadata = @{ + 'Custom.Step.Unknown' = @{ + RequiredCapabilities = { 'Dynamic.Cap' } + } + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'ScriptBlock' + } + } +} + +Describe 'New-IdlePlan - step metadata catalog integration' { + + It 'fails fast with MissingStepTypeMetadata when step type has no metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-no-metadata.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + # Provide a custom StepRegistry for the unknown step type + $providers = @{ + StepRegistry = @{ + 'Custom.Step.Unknown' = 'Invoke-CustomStepUnknown' + } + } + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingStepTypeMetadata' + $_.Exception.Message | Should -Match 'Custom.Step.Unknown' + $_.Exception.Message | Should -Match 'Import/load the step pack' + $_.Exception.Message | Should -Match 'Providers.StepMetadata' + } + } + + It 'derives capabilities from step pack metadata (IdLE.Step.DisableIdentity)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-builtin.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + } + + It 'derives capabilities from DirectorySync step pack (IdLE.Step.TriggerDirectorySync)' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-with-dirsync.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'DirSyncProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } -Force + + $providers = @{ + DirectorySync = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].Type | Should -Be 'IdLE.Step.TriggerDirectorySync' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Trigger' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.DirectorySync.Status' + } + + It 'validates OnFailureSteps capabilities from metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-onfailure.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.OnFailureSteps.Count | Should -Be 1 + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Contain 'IdLE.Identity.Disable' + } + + It 'validates entitlement capabilities from metadata' { + $wfPath = Join-Path -Path $fixturesPath -ChildPath 'joiner-entitlements.psd1' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingCapabilities' + $_.Exception.Message | Should -Match 'IdLE\.Entitlement' + } + + $provider = [pscustomobject]@{ Name = 'EntProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') + } -Force + + $providers = @{ Entitlement = $provider } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps.Count | Should -Be 1 + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.List' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Grant' + $plan.Steps[0].RequiresCapabilities | Should -Contain 'IdLE.Entitlement.Revoke' + } +} diff --git a/tests/fixtures/workflows/joiner-with-dirsync.psd1 b/tests/fixtures/workflows/joiner-with-dirsync.psd1 new file mode 100644 index 00000000..34b93ebe --- /dev/null +++ b/tests/fixtures/workflows/joiner-with-dirsync.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Joiner - DirectorySync Test' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Trigger directory sync' + Type = 'IdLE.Step.TriggerDirectorySync' + With = @{ + AuthSessionName = 'DirSync' + PolicyType = 'Delta' + Wait = $false + Provider = 'DirectorySync' + } + } + ) +} From 58b822c2a6aa63597acb00614c4dda4f172ec278 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:32:07 +0000 Subject: [PATCH 4/8] Update documentation for step metadata catalog ownership - Update provider-capabilities.md to reflect new ownership model - Document discovery, merge, and supplement-only behavior - Document breaking change (no RequiresCapabilities in workflows) - Add error documentation (MissingStepTypeMetadata, DuplicateStepTypeMetadata) Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/advanced/provider-capabilities.md | 115 +++++++++++++++++++++---- 1 file changed, 96 insertions(+), 19 deletions(-) diff --git a/docs/advanced/provider-capabilities.md b/docs/advanced/provider-capabilities.md index 5d809d40..f508ad15 100644 --- a/docs/advanced/provider-capabilities.md +++ b/docs/advanced/provider-capabilities.md @@ -48,12 +48,18 @@ Workflow Definition (PSD1) v Plan Builder (New-IdlePlan / New-IdlePlanObject) | - |-- normalizes steps (Name/Type/With/Condition/RequiresCapabilities) + |-- loads step metadata catalog: + | - discovers loaded IdLE.Steps.* modules + | - merges their Get-IdleStepMetadataCatalog outputs + | - applies host supplements (Providers.StepMetadata) | - |-- NEW: capability validation (fail fast) - | - collect required capabilities from steps - | - discover available capabilities from providers - | - compare and throw on missing capabilities + |-- normalizes steps (Name/Type/With/Condition) + | - derives RequiresCapabilities from metadata catalog + | + |-- capability validation (fail fast) + | - collects required capabilities from all steps + | - discovers available capabilities from providers + | - compares and throws on missing capabilities | v Plan artifact (IdLE.Plan) is created @@ -98,36 +104,93 @@ IdLE includes a reusable Pester contract to enforce this. ## Step requirements -Steps can declare required capabilities in workflow definitions using the optional key: +Steps declare required capabilities via **step metadata catalogs** owned by step packs. + +### Step pack ownership + +Step packs (`IdLE.Steps.*` modules) own metadata for their step types via the `Get-IdleStepMetadataCatalog` function. + +Each step pack exports a case-insensitive hashtable mapping: +- **Key**: `StepType` (string, e.g., `IdLE.Step.DisableIdentity`) +- **Value**: metadata hashtable containing at least: + - `RequiredCapabilities`: string or string[] (normalized to string[] by Core) + +Example from `IdLE.Steps.Common`: + +```powershell +function Get-IdleStepMetadataCatalog { + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + $catalog['IdLE.Step.DisableIdentity'] = @{ + RequiredCapabilities = @('IdLE.Identity.Disable') + } -- `RequiresCapabilities` + $catalog['IdLE.Step.EnsureAttribute'] = @{ + RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') + } -Supported shapes: + return $catalog +} +``` + +### Discovery and merge + +During plan building, `IdLE.Core`: -- missing / `$null` -> no requirements -- string -> single capability -- string array -> multiple capabilities +1. Discovers all loaded modules matching `IdLE.Steps.*` that export `Get-IdleStepMetadataCatalog` +2. Calls each function and merges the returned catalogs deterministically (by module name ascending) +3. Fails fast with `DuplicateStepTypeMetadata` if the same step type appears in multiple step packs -Example: +### Host supplements (custom step types only) + +Hosts may provide metadata for **new, host-defined** step types via `Providers.StepMetadata`: +```powershell +$providers = @{ + StepMetadata = @{ + 'Custom.Step.SpecialAction' = @{ + RequiredCapabilities = @('Custom.Capability.SpecialAction') + } + } +} +``` + +**Important**: Host metadata is **supplement-only**. Hosts cannot override step pack metadata. Attempting to provide metadata for a step type already owned by a loaded step pack will result in `DuplicateStepTypeMetadata`. + +### Breaking change (pre-1.0) + +Workflow definitions must **not** declare `RequiredCapabilities` or `RequiresCapabilities` on individual steps. + +Capabilities are derived from step metadata catalogs during plan building. + +**Before** (deprecated): ```powershell @{ Name = 'Disable identity' - Type = 'DisableIdentity' - RequiresCapabilities = @('IdLE.Identity.Read', 'IdLE.Identity.Disable') + Type = 'IdLE.Step.DisableIdentity' + RequiresCapabilities = @('IdLE.Identity.Disable') # DEPRECATED } ``` -During planning, IdLE normalizes this into a stable, sorted, unique string array on each plan step. +**After** (correct): +```powershell +@{ + Name = 'Disable identity' + Type = 'IdLE.Step.DisableIdentity' + # Capabilities are derived from IdLE.Steps.Common metadata catalog +} +``` ## Capability validation Capability validation is performed during plan build: -1. Collect required capabilities from all steps (`RequiresCapabilities`) -2. Discover available capabilities from all provider instances passed via `-Providers` -3. Compare required vs. available -4. Throw a deterministic error if any required capabilities are missing +1. Load step metadata catalog (from step packs and host supplements) +2. Normalize steps and derive `RequiresCapabilities` from metadata +3. Collect required capabilities from all steps (including `OnFailureSteps`) +4. Discover available capabilities from all provider instances passed via `-Providers` +5. Compare required vs. available +6. Throw a deterministic error if any required capabilities are missing The thrown error message includes: @@ -137,6 +200,20 @@ The thrown error message includes: This is designed for good UX and for automated diagnostics in CI logs. +### Error: MissingStepTypeMetadata + +If a workflow references a step type that has no metadata entry, plan building fails with `MissingStepTypeMetadata`. + +Remediation: +1. Import/load the step pack module (`IdLE.Steps.*`) that owns the step type, OR +2. For custom/host-defined step types only, provide `Providers.StepMetadata` + +### Error: DuplicateStepTypeMetadata + +If the same step type appears in multiple step packs, or if a host attempts to override step pack metadata, plan building fails with `DuplicateStepTypeMetadata`. + +This ensures clear ownership and prevents ambiguous behavior. + ## Provider discovery from `-Providers` The engine treats the `-Providers` argument as a host-controlled "bag of objects". From 5b6cf8eb3e3cead7bd2dd54fbb5f6885fdbc257b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:36:55 +0000 Subject: [PATCH 5/8] Final documentation cleanup - update provider-ad.md - Update wording to reflect step pack metadata ownership - Remove outdated reference to steps declaring RequiresCapabilities - All tests passing (240/240), ScriptAnalyzer clean Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index ce2dbbf2..b3ea84d1 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -311,7 +311,7 @@ The following built-in steps in `IdLE.Steps.Common` work with the AD provider: - **IdLE.Step.EnsureAttribute** - Set/update user attributes - **IdLE.Step.EnsureEntitlement** - Manage group memberships -All steps declare `RequiresCapabilities` for plan-time validation. +Step metadata (including required capabilities) is provided by step pack modules (`IdLE.Steps.Common`) and used for plan-time validation. --- From bc5a76c28a38931308be0f284e00c7b8e259988c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 10:05:17 +0000 Subject: [PATCH 6/8] Refactor: eliminate ScriptBlock validation duplication - Remove duplicate Test-IdleMetadataForScriptBlocks definitions - Create shared Assert-IdleStepMetadataNoScriptBlock wrapper - Reuse existing Assert-IdleNoScriptBlock function - Maintains same error messages with metadata-specific context - All 240 tests passing, ScriptAnalyzer clean Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Resolve-IdleStepMetadataCatalog.ps1 | 104 ++++++------------ 1 file changed, 32 insertions(+), 72 deletions(-) diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index e758b097..7d0e5881 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -102,6 +102,36 @@ function Resolve-IdleStepMetadataCatalog { ) } + # Helper: Validate metadata contains no ScriptBlocks (wrapper around Assert-IdleNoScriptBlock). + function Assert-IdleStepMetadataNoScriptBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [string] $StepType, + + [Parameter(Mandatory)] + [string] $SourceName + ) + + try { + Assert-IdleNoScriptBlock -InputObject $Value -Path $Path + } + catch { + # Rethrow with metadata-specific error message + throw [System.ArgumentException]::new( + "$SourceName entry for step type '$StepType' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", + 'Providers' + ) + } + } + # Helper: Get host-provided StepMetadata if available. function Get-IdleHostStepMetadata { [CmdletBinding()] @@ -180,46 +210,11 @@ function Resolve-IdleStepMetadataCatalog { } # Validate metadata shape (data-only, no ScriptBlocks). - function Test-IdleMetadataForScriptBlocks { - param( - [Parameter(Mandatory)] - [AllowNull()] - [object] $Value, - - [Parameter(Mandatory)] - [string] $Path - ) - - if ($null -eq $Value) { - return - } - - if ($Value -is [scriptblock]) { - throw [System.ArgumentException]::new( - "$SourceModuleName entry for step type '$key' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", - 'Providers' - ) - } - - if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary]) { - foreach ($k in $Value.Keys) { - Test-IdleMetadataForScriptBlocks -Value $Value[$k] -Path "$Path.$k" - } - } - elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { - $index = 0 - foreach ($item in $Value) { - Test-IdleMetadataForScriptBlocks -Value $item -Path "$Path[$index]" - $index++ - } - } - } - foreach ($metaKey in $value.Keys) { $metaValue = $value[$metaKey] # Recursively validate no ScriptBlocks anywhere in metadata - Test-IdleMetadataForScriptBlocks -Value $metaValue -Path $metaKey + Assert-IdleStepMetadataNoScriptBlock -Value $metaValue -Path $metaKey -StepType $key -SourceName $SourceModuleName if ($metaKey -eq 'RequiredCapabilities') { Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName $SourceModuleName @@ -285,46 +280,11 @@ function Resolve-IdleStepMetadataCatalog { } # Validate metadata shape (data-only, no ScriptBlocks). - function Test-IdleMetadataForScriptBlocks { - param( - [Parameter(Mandatory)] - [AllowNull()] - [object] $Value, - - [Parameter(Mandatory)] - [string] $Path - ) - - if ($null -eq $Value) { - return - } - - if ($Value -is [scriptblock]) { - throw [System.ArgumentException]::new( - "Providers.StepMetadata entry for step type '$key' contains a ScriptBlock at '$Path'. ScriptBlocks are not allowed (data-only boundary).", - 'Providers' - ) - } - - if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary]) { - foreach ($k in $Value.Keys) { - Test-IdleMetadataForScriptBlocks -Value $Value[$k] -Path "$Path.$k" - } - } - elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { - $index = 0 - foreach ($item in $Value) { - Test-IdleMetadataForScriptBlocks -Value $item -Path "$Path[$index]" - $index++ - } - } - } - foreach ($metaKey in $value.Keys) { $metaValue = $value[$metaKey] # Recursively validate no ScriptBlocks anywhere in metadata - Test-IdleMetadataForScriptBlocks -Value $metaValue -Path $metaKey + Assert-IdleStepMetadataNoScriptBlock -Value $metaValue -Path $metaKey -StepType $key -SourceName 'Providers.StepMetadata' if ($metaKey -eq 'RequiredCapabilities') { Test-IdleRequiredCapabilities -Value $metaValue -StepType $key -SourceName 'Providers.StepMetadata' From 027ade7c0b984784be1991514cb66fa99f1ec9a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 10:16:17 +0000 Subject: [PATCH 7/8] Address review feedback: clean up docs and add -All to Get-Module - Remove Before/After examples from documentation (new behavior only) - Add -All parameter to Get-Module for nested module discovery - Add explanatory comment to test fixture about DirectorySync focus - All tests passing, ScriptAnalyzer clean Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/advanced/provider-capabilities.md | 22 +++++-------------- .../Resolve-IdleStepMetadataCatalog.ps1 | 2 +- .../workflows/joiner-with-dirsync.psd1 | 4 +++- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/docs/advanced/provider-capabilities.md b/docs/advanced/provider-capabilities.md index f508ad15..5da97f4d 100644 --- a/docs/advanced/provider-capabilities.md +++ b/docs/advanced/provider-capabilities.md @@ -157,27 +157,17 @@ $providers = @{ **Important**: Host metadata is **supplement-only**. Hosts cannot override step pack metadata. Attempting to provide metadata for a step type already owned by a loaded step pack will result in `DuplicateStepTypeMetadata`. -### Breaking change (pre-1.0) +Workflow definitions must **not** declare `RequiredCapabilities` or `RequiresCapabilities` on individual steps. Capabilities are derived from step metadata catalogs during plan building. -Workflow definitions must **not** declare `RequiredCapabilities` or `RequiresCapabilities` on individual steps. - -Capabilities are derived from step metadata catalogs during plan building. - -**Before** (deprecated): -```powershell -@{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - RequiresCapabilities = @('IdLE.Identity.Disable') # DEPRECATED -} -``` - -**After** (correct): +Example workflow step: ```powershell @{ Name = 'Disable identity' Type = 'IdLE.Step.DisableIdentity' - # Capabilities are derived from IdLE.Steps.Common metadata catalog + With = @{ + IdentityKey = '{{ Request.Username }}' + Provider = 'Identity' + } } ``` diff --git a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 index 7d0e5881..d415f8b5 100644 --- a/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleStepMetadataCatalog.ps1 @@ -161,7 +161,7 @@ function Resolve-IdleStepMetadataCatalog { [CmdletBinding()] param() - $loadedModules = Get-Module -Name 'IdLE.Steps.*' + $loadedModules = Get-Module -Name 'IdLE.Steps.*' -All if ($null -eq $loadedModules) { return @() } diff --git a/tests/fixtures/workflows/joiner-with-dirsync.psd1 b/tests/fixtures/workflows/joiner-with-dirsync.psd1 index 34b93ebe..cf20dbba 100644 --- a/tests/fixtures/workflows/joiner-with-dirsync.psd1 +++ b/tests/fixtures/workflows/joiner-with-dirsync.psd1 @@ -1,5 +1,7 @@ @{ - Name = 'Joiner - DirectorySync Test' + # This workflow tests DirectorySync step metadata resolution during the Joiner lifecycle event. + # It intentionally omits actual joiner steps to focus solely on DirectorySync step capability derivation. + Name = 'Joiner - DirectorySync Metadata Test' LifecycleEvent = 'Joiner' Steps = @( @{ From 33af6a915daa48b5d7752cc487efa23d543c0e5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:13:43 +0000 Subject: [PATCH 8/8] Remove unnecessary workflow constraints documentation - Removed paragraph about not declaring RequiredCapabilities in workflows - Removed example workflow step showing current format - Documentation now focuses purely on how the system works without referencing old behavior - All tests passing Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/advanced/provider-capabilities.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/advanced/provider-capabilities.md b/docs/advanced/provider-capabilities.md index 5da97f4d..e74d2594 100644 --- a/docs/advanced/provider-capabilities.md +++ b/docs/advanced/provider-capabilities.md @@ -157,20 +157,6 @@ $providers = @{ **Important**: Host metadata is **supplement-only**. Hosts cannot override step pack metadata. Attempting to provide metadata for a step type already owned by a loaded step pack will result in `DuplicateStepTypeMetadata`. -Workflow definitions must **not** declare `RequiredCapabilities` or `RequiresCapabilities` on individual steps. Capabilities are derived from step metadata catalogs during plan building. - -Example workflow step: -```powershell -@{ - Name = 'Disable identity' - Type = 'IdLE.Step.DisableIdentity' - With = @{ - IdentityKey = '{{ Request.Username }}' - Provider = 'Identity' - } -} -``` - ## Capability validation Capability validation is performed during plan build: