diff --git a/README.md b/README.md index 0c8dc33..8a9a588 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The module exports: | `Format-Yaml` | Normalize YAML streams without projecting representation nodes to PowerShell values. | | `Import-Yaml` | Strictly decode and parse YAML files. | | `Merge-Yaml` | Merge complete YAML streams without losing representation graph details. | +| `Remove-YamlEntry` | Remove selected entries or documents without losing representation graph details. | | `Test-Yaml` | Test YAML syntax, tags, duplicate keys, and configured resource limits. | ## Parse YAML @@ -217,6 +218,29 @@ creation, charged merge operations, and the resulting stream graph. Index, fingerprint, candidate, alias-traversal, and equality work all consume the merge operation budget. Alias and expanded-tag budgets are also enforced on the result. +## Remove YAML entries + +`Remove-YamlEntry` removes mapping entries, sequence items, or whole documents directly from a deep-cloned representation graph. Input array elements and pipeline records are joined with LF as one YAML stream, matching `Format-Yaml`. + +```powershell +$cleanYaml = Get-Content -Path '.\config.yaml' | + Remove-YamlEntry -Path @('/metadata/internalIdentifier', '/services/1/deprecated') +``` + +Paths use [RFC 6901 JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901). An empty pointer selects a document root, `~0` addresses a tilde, and `~1` addresses a slash. Mapping tokens match only scalar YAML string keys by ordinal content, so numeric, complex, and unknown-tagged keys are never coerced or guessed. Sequence tokens must be `0` or a non-zero decimal index without signs or leading zeros. + +Document zero is selected by default. Use `-DocumentIndex` for another zero-based document, or `-AllDocuments` to apply every path independently to every original document. `-IgnoreMissing` skips only unresolved document/path combinations. + +```powershell +$withoutTemporaryData = Remove-YamlEntry $stream '/temporary' ` + -AllDocuments -IgnoreMissing -Indent 4 +$withoutSecondDocument = Remove-YamlEntry $stream '' -DocumentIndex 1 +``` + +Every required target is resolved before mutation, duplicate logical targets are coalesced, and ancestors subsume descendants. Removing inside a shared mapping or sequence changes every alias to that node; removing an alias edge removes only that edge. Original sequence indexes and document roots are removed in descending order. + +Output preserves unaffected tags, anchors, aliases, shared and cyclic identity, complex keys, mapping order, and document order. It is one deterministic string with LF line endings and no final newline; removing every document returns an empty string. Parser limits are mirrored, while cloning, pointer and mutation work, and output validation use independent resource ceilings. + ## Export YAML files `Export-Yaml` aggregates pipeline records like `ConvertTo-Yaml`, serializes the @@ -265,6 +289,8 @@ limit violations. Unexpected runtime failures are not suppressed. YAML 1.2 core schema. - YAML stream merging compares effective tags and structural representation values without projecting through PowerShell objects. +- YAML entry removal resolves JSON Pointers against an immutable representation + clone and mutates shared nodes by identity without object projection. - Parsing defaults to depth 100, 100000 nodes, 1000 aliases, 1048576 decoded characters per scalar, 1024 characters per expanded tag, 65536 cumulative expanded tag characters, and 4096 digits per numeric scalar. The diff --git a/examples/General.ps1 b/examples/General.ps1 index dc69e5a..72a0af7 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -61,6 +61,11 @@ service: $mergedYaml = Merge-Yaml -InputObject @($baseYaml, $overlayYaml) $mergedYaml +# Remove selected YAML entries without projecting the representation graph. +$cleanedYaml = $mergedYaml | + Remove-YamlEntry -Path @('/service/image', '/service/ports/0') +$cleanedYaml + # Atomically export one file, then import it with strict decoding. $configPath = Join-Path $env:TEMP 'yaml-example.yaml' $config | Export-Yaml -Path $configPath -PassThru diff --git a/src/functions/private/Add-YamlRemovalWork.ps1 b/src/functions/private/Add-YamlRemovalWork.ps1 new file mode 100644 index 0000000..3c3f514 --- /dev/null +++ b/src/functions/private/Add-YamlRemovalWork.ps1 @@ -0,0 +1,37 @@ +function Add-YamlRemovalWork { + <# + .SYNOPSIS + Charges deterministic operations to the YAML removal work budget. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $State, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $Count = 1, + + [Parameter(Mandatory)] + [string] $Operation, + + [Parameter()] + [AllowNull()] + [pscustomobject] $Node + ) + + $State.Count = [long] $State.Count + $Count + if ($State.Count -le $State.MaxNodes) { + return + } + + $exception = New-YamlRemovalException -Node $Node ` + -ErrorId 'YamlRemovalWorkLimitExceeded' -Message ( + "YAML removal operation '$Operation' exceeded the configured invocation work " + + "limit of $($State.MaxNodes) operations." + ) + $exception.Data['YamlRemovalWorkCount'] = $State.Count + $exception.Data['YamlRemovalWorkLimit'] = $State.MaxNodes + $exception.Data['YamlRemovalWorkOperation'] = $Operation + throw $exception +} diff --git a/src/functions/private/Assert-YamlNodeKeyUnique.ps1 b/src/functions/private/Assert-YamlNodeKeyUnique.ps1 new file mode 100644 index 0000000..3472a34 --- /dev/null +++ b/src/functions/private/Assert-YamlNodeKeyUnique.ps1 @@ -0,0 +1,61 @@ +function Assert-YamlNodeKeyUnique { + <# + .SYNOPSIS + Indexes one representation key and confirms equality for fingerprint candidates. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[string, object]] $Buckets, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.Dictionary[int, string]] $FingerprintCache, + + [Parameter(Mandatory)] + [System.Security.Cryptography.HashAlgorithm] $FingerprintHasher, + + [Parameter(Mandatory)] + [string] $DuplicateMessage, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState, + + [Parameter()] + [AllowNull()] + [pscustomobject] $EqualityState, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $EqualityFingerprintCache + ) + + $fingerprint = Get-YamlNodeFingerprint -Node $Node ` + -Active ([System.Collections.Generic.HashSet[int]]::new()) ` + -Cache $FingerprintCache -Hasher $FingerprintHasher ` + -RemovalWorkState $RemovalWorkState + $bucket = $null + if (-not $Buckets.TryGetValue($fingerprint, [ref] $bucket)) { + $bucket = [System.Collections.Generic.List[object]]::new() + $Buckets[$fingerprint] = $bucket + } elseif ($null -eq $EqualityState) { + throw (New-YamlException -Start $Node.Start -End $Node.End ` + -ErrorId 'YamlDuplicateKey' -Message $DuplicateMessage) + } else { + foreach ($candidate in $bucket) { + if (Test-YamlMergeNodeEqual -Node $candidate -OtherNode $Node ` + -State $EqualityState ` + -LeftFingerprintCache $EqualityFingerprintCache ` + -RightFingerprintCache $EqualityFingerprintCache) { + throw (New-YamlException -Start $Node.Start -End $Node.End ` + -ErrorId 'YamlDuplicateKey' -Message $DuplicateMessage) + } + } + } + $bucket.Add($Node) +} diff --git a/src/functions/private/Assert-YamlRemovalGraph.ps1 b/src/functions/private/Assert-YamlRemovalGraph.ps1 new file mode 100644 index 0000000..db88ce4 --- /dev/null +++ b/src/functions/private/Assert-YamlRemovalGraph.ps1 @@ -0,0 +1,211 @@ +function Assert-YamlRemovalGraph { + <# + .SYNOPSIS + Validates a removed representation graph and its resource budgets. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Documents, + + [Parameter(Mandatory)] + [int] $Depth, + + [Parameter(Mandatory)] + [int] $MaxNodes, + + [Parameter(Mandatory)] + [int] $MaxAliases, + + [Parameter(Mandatory)] + [int] $MaxScalarLength, + + [Parameter(Mandatory)] + [int] $MaxTagLength, + + [Parameter(Mandatory)] + [int] $MaxTotalTagLength, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $visited = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + foreach ($document in $Documents) { + $pending.Push([pscustomobject]@{ Node = $document; Depth = 1 }) + } + $nodeCount = 0 + $aliasCount = 0 + $totalTagLength = 0L + + while ($pending.Count -gt 0) { + $item = $pending.Pop() + $node = $item.Node + Add-YamlRemovalWork -State $State -Operation 'output validation' -Node $node + if (-not $visited.Add($node.Id)) { + continue + } + + $nodeCount++ + if ($nodeCount -gt $MaxNodes) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalNodeLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured limit of $MaxNodes nodes." + )) + } + if ($item.Depth -gt $Depth) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalDepthLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured depth of $Depth." + )) + } + + if ($node.Kind -eq 'Alias') { + $aliasCount++ + if ($aliasCount -gt $MaxAliases) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalAliasLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured limit of $MaxAliases aliases." + )) + } + $pending.Push([pscustomobject]@{ Node = $node.Target; Depth = $item.Depth }) + continue + } + + $tagLength = ([string] $node.Tag).Length + if ($tagLength -gt $MaxTagLength) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalTagLimitExceeded' -Message ( + "A tag in the resulting YAML graph exceeds the configured limit of " + + "$MaxTagLength characters." + )) + } + $totalTagLength += $tagLength + if ($totalTagLength -gt $MaxTotalTagLength) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalTagLimitExceeded' -Message ( + "The resulting YAML graph exceeds the configured cumulative tag limit of " + + "$MaxTotalTagLength characters." + )) + } + + if ($node.Kind -eq 'Scalar') { + if ((Get-YamlRuneCount -Text ([string] $node.Value)) -gt $MaxScalarLength) { + throw (New-YamlRemovalException -Node $node ` + -ErrorId 'YamlRemovalScalarLimitExceeded' -Message ( + "A scalar in the resulting YAML graph exceeds the configured limit of " + + "$MaxScalarLength characters." + )) + } + continue + } + + if ($node.Kind -eq 'Sequence') { + if ($node.Tag -cin @( + 'tag:yaml.org,2002:omap', + 'tag:yaml.org,2002:pairs' + )) { + foreach ($sequenceItem in $node.Items) { + $effectiveItem = Get-YamlRemovalNode -Node $sequenceItem -State $State + if ($effectiveItem.Kind -ne 'Mapping' -or + $effectiveItem.Entries.Count -ne 1) { + throw (New-YamlException -Start $sequenceItem.Start ` + -End $sequenceItem.End -ErrorId 'YamlInvalidTaggedCollection' ` + -Message ( + "YAML tag '$($node.Tag)' requires a sequence of one-entry mappings." + )) + } + } + } + for ($index = $node.Items.Count - 1; $index -ge 0; $index--) { + $pending.Push([pscustomobject]@{ + Node = $node.Items[$index] + Depth = $item.Depth + 1 + }) + } + continue + } + + if ($node.Tag -ceq 'tag:yaml.org,2002:set') { + foreach ($entry in $node.Entries) { + $effectiveValue = Get-YamlRemovalNode -Node $entry.Value -State $State + $resolvedValue = if ($effectiveValue.Kind -eq 'Scalar') { + (Resolve-YamlScalar -Node $effectiveValue).Value + } else { + [System.Management.Automation.Internal.AutomationNull]::Value + } + if ($effectiveValue.Kind -ne 'Scalar' -or $null -ne $resolvedValue) { + throw (New-YamlException -Start $entry.Value.Start -End $entry.Value.End ` + -ErrorId 'YamlInvalidTaggedCollection' -Message ( + 'Every value in a YAML set must be null.' + )) + } + } + + } + for ($index = $node.Entries.Count - 1; $index -ge 0; $index--) { + $pending.Push([pscustomobject]@{ + Node = $node.Entries[$index].Value + Depth = $item.Depth + 1 + }) + $pending.Push([pscustomobject]@{ + Node = $node.Entries[$index].Key + Depth = $item.Depth + 1 + }) + } + } + + $fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + $fingerprintHasher = [System.Security.Cryptography.SHA256]::Create() + $fingerprintWorkState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + $equalityWorkState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + $equalityState = [pscustomobject]@{ + MaxNodes = $MaxNodes + FingerprintHasher = $fingerprintHasher + WorkState = $equalityWorkState + MutationState = [pscustomobject]@{ Version = 0L } + IndexDependents = [System.Collections.Generic.Dictionary[int, object]]::new() + Cache = [System.Collections.Generic.Dictionary[string, bool]]::new( + [System.StringComparer]::Ordinal + ) + InputIndex = 0 + } + $equalityFingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + try { + foreach ($document in $Documents) { + try { + Test-YamlNodeGraph -Node $document ` + -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher ` + -RemovalWorkState $fingerprintWorkState -EqualityState $equalityState ` + -EqualityFingerprintCache $equalityFingerprintCache + } catch { + if ($_.Exception.Data.Contains('YamlErrorId') -and + $_.Exception.Data['YamlErrorId'] -ceq 'YamlMergeWorkLimitExceeded') { + $exception = New-YamlRemovalException -Node $document ` + -ErrorId 'YamlRemovalWorkLimitExceeded' -Message ( + 'Post-removal duplicate-key graph comparison exceeded the configured ' + + "invocation work limit of $MaxNodes operations." + ) + $exception.Data['YamlRemovalWorkCount'] = $equalityWorkState.Count + $exception.Data['YamlRemovalWorkLimit'] = $MaxNodes + $exception.Data['YamlRemovalWorkOperation'] = ( + 'duplicate-key graph comparison' + ) + throw $exception + } + throw + } + } + } finally { + $fingerprintHasher.Dispose() + } +} diff --git a/src/functions/private/ConvertFrom-YamlJsonPointer.ps1 b/src/functions/private/ConvertFrom-YamlJsonPointer.ps1 new file mode 100644 index 0000000..da99042 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlJsonPointer.ps1 @@ -0,0 +1,66 @@ +function ConvertFrom-YamlJsonPointer { + <# + .SYNOPSIS + Parses and strictly decodes one RFC 6901 JSON Pointer. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Pointer, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $workCount = [Math]::Max(1, $Pointer.Length) + Add-YamlRemovalWork -State $State -Count $workCount -Operation 'pointer parsing' + if ($Pointer.Length -eq 0) { + return New-YamlValueBox -Value ([string[]]::new(0)) + } + if ($Pointer[0] -cne '/') { + throw (New-YamlRemovalException -ErrorId 'YamlRemovalInvalidPointer' -Message ( + 'A YAML removal path must be empty or start with a slash as required by RFC 6901.' + )) + } + + $rawTokens = $Pointer.Substring(1).Split( + [char[]] @('/'), + [System.StringSplitOptions]::None + ) + $tokens = [System.Collections.Generic.List[string]]::new() + foreach ($rawToken in $rawTokens) { + $decoded = [System.Text.StringBuilder]::new() + for ($index = 0; $index -lt $rawToken.Length; $index++) { + $character = $rawToken[$index] + if ($character -cne '~') { + [void] $decoded.Append($character) + continue + } + + if ($index + 1 -ge $rawToken.Length) { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalInvalidPointerEscape' -Message ( + 'A YAML removal path contains an incomplete JSON Pointer tilde escape.' + )) + } + $escaped = $rawToken[$index + 1] + if ($escaped -ceq '0') { + [void] $decoded.Append('~') + } elseif ($escaped -ceq '1') { + [void] $decoded.Append('/') + } else { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalInvalidPointerEscape' -Message ( + "A YAML removal path contains invalid JSON Pointer escape '~$escaped'; " + + "only '~0' and '~1' are valid." + )) + } + $index++ + } + $tokens.Add($decoded.ToString()) + } + + return New-YamlValueBox -Value ([string[]] $tokens.ToArray()) +} diff --git a/src/functions/private/Get-YamlNodeFingerprint.ps1 b/src/functions/private/Get-YamlNodeFingerprint.ps1 index 37ca9d9..6147873 100644 --- a/src/functions/private/Get-YamlNodeFingerprint.ps1 +++ b/src/functions/private/Get-YamlNodeFingerprint.ps1 @@ -18,7 +18,11 @@ function Get-YamlNodeFingerprint { [System.Collections.Generic.Dictionary[int, string]] $Cache, [Parameter(Mandatory)] - [System.Security.Cryptography.HashAlgorithm] $Hasher + [System.Security.Cryptography.HashAlgorithm] $Hasher, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState ) $root = [pscustomobject]@{ Value = '' } @@ -48,6 +52,10 @@ function Get-YamlNodeFingerprint { [void] $stack.Pop() continue } + if ($null -ne $RemovalWorkState) { + Add-YamlRemovalWork -State $RemovalWorkState ` + -Operation 'duplicate-key fingerprint' -Node $effective + } if (-not $Active.Add($effective.Id)) { throw (New-YamlException -Start $effective.Start -End $effective.End ` -ErrorId 'YamlCyclicMappingKey' -Message ( diff --git a/src/functions/private/Get-YamlRemovalNode.ps1 b/src/functions/private/Get-YamlRemovalNode.ps1 new file mode 100644 index 0000000..6f5ee2e --- /dev/null +++ b/src/functions/private/Get-YamlRemovalNode.ps1 @@ -0,0 +1,35 @@ +function Get-YamlRemovalNode { + <# + .SYNOPSIS + Resolves aliases to an effective YAML node with cycle-safe accounting. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $effective = $Node + $visitedAliases = [System.Collections.Generic.HashSet[int]]::new() + while ($effective.Kind -eq 'Alias') { + Add-YamlRemovalWork -State $State -Operation 'alias traversal' -Node $effective + if (-not $visitedAliases.Add($effective.Id)) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalAliasCycle' -Message ( + 'A YAML alias-only cycle cannot be traversed by a removal path.' + )) + } + if ($null -eq $effective.Target) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalInvalidGraph' -Message ( + 'A YAML alias in the removal graph has no target.' + )) + } + $effective = $effective.Target + } + Write-Output -InputObject $effective -NoEnumerate +} diff --git a/src/functions/private/New-YamlRemovalException.ps1 b/src/functions/private/New-YamlRemovalException.ps1 new file mode 100644 index 0000000..82853ad --- /dev/null +++ b/src/functions/private/New-YamlRemovalException.ps1 @@ -0,0 +1,31 @@ +function New-YamlRemovalException { + <# + .SYNOPSIS + Creates a classified exception for a YAML removal failure. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Constructs an in-memory exception without changing system state.' + )] + [CmdletBinding()] + [OutputType([System.FormatException])] + param ( + [Parameter(Mandatory)] + [string] $ErrorId, + + [Parameter(Mandatory)] + [string] $Message, + + [Parameter()] + [AllowNull()] + [pscustomobject] $Node + ) + + if ($null -eq $Node) { + $mark = New-YamlMark -Index 0 -Line 0 -Column 0 + return New-YamlException -Start $mark -End $mark -ErrorId $ErrorId -Message $Message + } + + return New-YamlException -Start $Node.Start -End $Node.End ` + -ErrorId $ErrorId -Message $Message +} diff --git a/src/functions/private/Remove-YamlRepresentationTarget.ps1 b/src/functions/private/Remove-YamlRepresentationTarget.ps1 new file mode 100644 index 0000000..7c7f555 --- /dev/null +++ b/src/functions/private/Remove-YamlRepresentationTarget.ps1 @@ -0,0 +1,105 @@ +function Remove-YamlRepresentationTarget { + <# + .SYNOPSIS + Applies resolved YAML removal targets in stable mutation order. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Mutates only an isolated in-memory representation graph.' + )] + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [System.Collections.Generic.List[object]] $Documents, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Targets, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + if ($Targets.Count -eq 0) { + return + } + Add-YamlRemovalWork -State $State -Count $Targets.Count -Operation 'target ordering' + $groups = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($target in $Targets) { + $groupKey = if ($target.Kind -ceq 'Document') { + 'Documents' + } else { + "Node:$($target.Parent.Id)" + } + if (-not $groups.ContainsKey($groupKey)) { + $groups[$groupKey] = [pscustomobject]@{ + Key = $groupKey + Depth = [int] $target.Depth + Targets = [System.Collections.Generic.List[object]]::new() + } + } elseif ($target.Depth -gt $groups[$groupKey].Depth) { + $groups[$groupKey].Depth = [int] $target.Depth + } + $groups[$groupKey].Targets.Add($target) + } + $orderedGroups = @( + $groups.Values | Sort-Object -Property @( + @{ Expression = { [int] $_.Depth }; Descending = $true } + @{ Expression = { [string] $_.Key }; Ascending = $true } + ) + ) + + foreach ($group in $orderedGroups) { + $orderedTargets = @( + $group.Targets | Sort-Object -Property @( + @{ Expression = { [int] $_.Index }; Descending = $true } + @{ Expression = { [string] $_.Kind }; Ascending = $true } + ) + ) + foreach ($target in $orderedTargets) { + Add-YamlRemovalWork -State $State -Operation 'target mutation' -Node $target.Node + if ($target.Kind -ceq 'Document') { + if ($target.Index -ge $Documents.Count -or -not [object]::ReferenceEquals( + $Documents[$target.Index], + $target.Node + )) { + throw (New-YamlRemovalException -Node $target.Node ` + -ErrorId 'YamlRemovalMutationConflict' -Message ( + 'A resolved YAML document target changed before mutation.' + )) + } + $Documents.RemoveAt($target.Index) + continue + } + + if ($target.Kind -ceq 'Sequence') { + if ($target.Index -ge $target.Parent.Items.Count -or + -not [object]::ReferenceEquals( + $target.Parent.Items[$target.Index], + $target.Edge + )) { + throw (New-YamlRemovalException -Node $target.Node ` + -ErrorId 'YamlRemovalMutationConflict' -Message ( + 'A resolved YAML sequence target changed before mutation.' + )) + } + $target.Parent.Items.RemoveAt($target.Index) + continue + } + + if ($target.Index -ge $target.Parent.Entries.Count -or + -not [object]::ReferenceEquals( + $target.Parent.Entries[$target.Index], + $target.Edge + )) { + throw (New-YamlRemovalException -Node $target.Node ` + -ErrorId 'YamlRemovalMutationConflict' -Message ( + 'A resolved YAML mapping target changed before mutation.' + )) + } + $target.Parent.Entries.RemoveAt($target.Index) + } + } +} diff --git a/src/functions/private/Resolve-YamlRemovalTarget.ps1 b/src/functions/private/Resolve-YamlRemovalTarget.ps1 new file mode 100644 index 0000000..08b2721 --- /dev/null +++ b/src/functions/private/Resolve-YamlRemovalTarget.ps1 @@ -0,0 +1,199 @@ +function Resolve-YamlRemovalTarget { + <# + .SYNOPSIS + Resolves one decoded JSON Pointer against one YAML document. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Document, + + [Parameter(Mandatory)] + [int] $DocumentIndex, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Pointer, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]] $Tokens, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $documentKey = "D:$DocumentIndex" + if ($Tokens.Count -eq 0) { + return [pscustomobject]@{ + Found = $true + Key = $documentKey + Kind = 'Document' + Parent = $null + Index = $DocumentIndex + Edge = $Document + Node = $Document + Depth = 0 + Path = [string[]] @($documentKey) + Pointer = $Pointer + DocumentIndex = $DocumentIndex + } + } + + $ancestors = [System.Collections.Generic.List[string]]::new() + $ancestors.Add($documentKey) + $current = $Document + for ($tokenIndex = 0; $tokenIndex -lt $Tokens.Count; $tokenIndex++) { + $token = $Tokens[$tokenIndex] + $isFinal = $tokenIndex -eq $Tokens.Count - 1 + $effective = Get-YamlRemovalNode -Node $current -State $State + Add-YamlRemovalWork -State $State -Operation 'pointer token resolution' ` + -Node $effective + + if ($effective.Kind -eq 'Mapping') { + $hasUnaddressableKey = $false + $matchingIndexes = [System.Collections.Generic.List[int]]::new() + for ($entryIndex = 0; $entryIndex -lt $effective.Entries.Count; $entryIndex++) { + $entry = $effective.Entries[$entryIndex] + Add-YamlRemovalWork -State $State -Operation 'mapping key scan' ` + -Node $entry.Key + $keyNode = Get-YamlRemovalNode -Node $entry.Key -State $State + if ($keyNode.Kind -ne 'Scalar') { + $hasUnaddressableKey = $true + continue + } + + $resolvedKey = (Resolve-YamlScalar -Node $keyNode).Value + $effectiveTag = Get-YamlEffectiveTag -Node $keyNode -Value $resolvedKey + if (-not [string]::Equals( + $effectiveTag, + 'tag:yaml.org,2002:str', + [System.StringComparison]::Ordinal + )) { + $hasUnaddressableKey = $true + continue + } + if ([string]::Equals( + [string] $keyNode.Value, + $token, + [System.StringComparison]::Ordinal + )) { + $matchingIndexes.Add($entryIndex) + } + } + + if ($matchingIndexes.Count -gt 1) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalAmbiguousTarget' -Message ( + "JSON Pointer '$Pointer' ambiguously matches more than one string key " + + "in YAML document index $DocumentIndex." + )) + } + if ($matchingIndexes.Count -eq 0) { + $guidance = if ($hasUnaddressableKey) { + ' The mapping contains complex, non-string, or tagged keys that cannot be ' + + 'addressed by JSON Pointer; no key was coerced or guessed.' + } else { + '' + } + return [pscustomobject]@{ + Found = $false + ErrorId = 'YamlRemovalPathNotFound' + Message = ( + "JSON Pointer '$Pointer' did not resolve in YAML document index " + + "$DocumentIndex at token '$token'.$guidance" + ) + Node = $effective + } + } + + $matchedIndex = $matchingIndexes[0] + $matchedEntry = $effective.Entries[$matchedIndex] + $edgeKey = "M:$($effective.Id):$matchedIndex" + if ($isFinal) { + return [pscustomobject]@{ + Found = $true + Key = $edgeKey + Kind = 'Mapping' + Parent = $effective + Index = $matchedIndex + Edge = $matchedEntry + Node = $matchedEntry.Value + Depth = $Tokens.Count + Path = [string[]] (@($ancestors.ToArray()) + $edgeKey) + Pointer = $Pointer + DocumentIndex = $DocumentIndex + } + } + $ancestors.Add($edgeKey) + $current = $matchedEntry.Value + continue + } + + if ($effective.Kind -eq 'Sequence') { + if ($token -cnotmatch '^(?:0|[1-9][0-9]*)$') { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalInvalidSequenceIndex' -Message ( + "JSON Pointer token '$token' is not a canonical non-negative decimal " + + 'YAML sequence index.' + )) + } + $sequenceIndex = 0 + if (-not [int]::TryParse( + $token, + [System.Globalization.NumberStyles]::None, + [System.Globalization.CultureInfo]::InvariantCulture, + [ref] $sequenceIndex + )) { + throw (New-YamlRemovalException -Node $effective ` + -ErrorId 'YamlRemovalInvalidSequenceIndex' -Message ( + "JSON Pointer token '$token' exceeds the supported YAML sequence index range." + )) + } + if ($sequenceIndex -ge $effective.Items.Count) { + return [pscustomobject]@{ + Found = $false + ErrorId = 'YamlRemovalPathNotFound' + Message = ( + "JSON Pointer '$Pointer' did not resolve in YAML document index " + + "$DocumentIndex because sequence index $sequenceIndex is out of range." + ) + Node = $effective + } + } + + $sequenceItem = $effective.Items[$sequenceIndex] + $edgeKey = "S:$($effective.Id):$sequenceIndex" + if ($isFinal) { + return [pscustomobject]@{ + Found = $true + Key = $edgeKey + Kind = 'Sequence' + Parent = $effective + Index = $sequenceIndex + Edge = $sequenceItem + Node = $sequenceItem + Depth = $Tokens.Count + Path = [string[]] (@($ancestors.ToArray()) + $edgeKey) + Pointer = $Pointer + DocumentIndex = $DocumentIndex + } + } + $ancestors.Add($edgeKey) + $current = $sequenceItem + continue + } + + return [pscustomobject]@{ + Found = $false + ErrorId = 'YamlRemovalPathNotFound' + Message = ( + "JSON Pointer '$Pointer' did not resolve in YAML document index " + + "$DocumentIndex because token '$token' traverses a scalar." + ) + Node = $effective + } + } +} diff --git a/src/functions/private/Select-YamlRemovalTarget.ps1 b/src/functions/private/Select-YamlRemovalTarget.ps1 new file mode 100644 index 0000000..fb541d0 --- /dev/null +++ b/src/functions/private/Select-YamlRemovalTarget.ps1 @@ -0,0 +1,105 @@ +function Select-YamlRemovalTarget { + <# + .SYNOPSIS + Coalesces duplicate targets and drops fully subsumed descendants. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Targets, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $coalesced = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($target in $Targets) { + Add-YamlRemovalWork -State $State -Operation 'target coalescing' -Node $target.Node + if ($coalesced.ContainsKey($target.Key)) { + $coalesced[$target.Key].PathSets.Add([string[]] $target.Path) + continue + } + + $pathSets = [System.Collections.Generic.List[object]]::new() + $pathSets.Add([string[]] $target.Path) + $coalesced[$target.Key] = [pscustomobject]@{ + Key = $target.Key + Kind = $target.Kind + Parent = $target.Parent + Index = $target.Index + Edge = $target.Edge + Node = $target.Node + Depth = $target.Depth + PathSets = $pathSets + Pointer = $target.Pointer + DocumentIndex = $target.DocumentIndex + } + } + + $pathRoot = [pscustomobject]@{ + Children = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + TargetKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + } + foreach ($target in $coalesced.Values) { + foreach ($path in $target.PathSets) { + $pathNode = $pathRoot + foreach ($edgeKey in $path) { + Add-YamlRemovalWork -State $State -Operation 'target path indexing' ` + -Node $target.Node + if (-not $pathNode.Children.ContainsKey($edgeKey)) { + $pathNode.Children[$edgeKey] = [pscustomobject]@{ + Children = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + TargetKeys = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + } + } + $pathNode = $pathNode.Children[$edgeKey] + } + [void] $pathNode.TargetKeys.Add($target.Key) + } + } + + $survivors = [System.Collections.Generic.List[object]]::new() + foreach ($candidate in $coalesced.Values) { + $allRequestsSubsumed = $true + foreach ($path in $candidate.PathSets) { + $requestSubsumed = $false + $pathNode = $pathRoot + for ($pathIndex = 0; $pathIndex -lt $path.Count - 1; $pathIndex++) { + Add-YamlRemovalWork -State $State -Operation 'target path ancestry' ` + -Node $candidate.Node + $edgeKey = $path[$pathIndex] + if (-not $pathNode.Children.ContainsKey($edgeKey)) { + break + } + $pathNode = $pathNode.Children[$edgeKey] + if ($pathNode.TargetKeys.Count -gt 1 -or + ($pathNode.TargetKeys.Count -eq 1 -and + -not $pathNode.TargetKeys.Contains($candidate.Key))) { + $requestSubsumed = $true + break + } + } + if (-not $requestSubsumed) { + $allRequestsSubsumed = $false + break + } + } + if (-not $allRequestsSubsumed) { + $survivors.Add($candidate) + } + } + + return New-YamlValueBox -Value ([object[]] $survivors.ToArray()) +} diff --git a/src/functions/private/Test-YamlMergeNodeEqual.ps1 b/src/functions/private/Test-YamlMergeNodeEqual.ps1 index 3eea333..e727c2a 100644 --- a/src/functions/private/Test-YamlMergeNodeEqual.ps1 +++ b/src/functions/private/Test-YamlMergeNodeEqual.ps1 @@ -80,7 +80,11 @@ function Test-YamlMergeNodeEqual { } $leftTag = Get-YamlMergeNodeTag -Node $left $rightTag = Get-YamlMergeNodeTag -Node $right - if ($leftTag -cne $rightTag) { + if (-not [string]::Equals( + $leftTag, + $rightTag, + [System.StringComparison]::Ordinal + )) { $State.Cache[$cacheKey] = $false return $false } diff --git a/src/functions/private/Test-YamlNodeGraph.ps1 b/src/functions/private/Test-YamlNodeGraph.ps1 index 5aa88b8..a130a64 100644 --- a/src/functions/private/Test-YamlNodeGraph.ps1 +++ b/src/functions/private/Test-YamlNodeGraph.ps1 @@ -17,7 +17,19 @@ function Test-YamlNodeGraph { [System.Collections.Generic.Dictionary[int, string]] $FingerprintCache, [Parameter(Mandatory)] - [System.Security.Cryptography.HashAlgorithm] $FingerprintHasher + [System.Security.Cryptography.HashAlgorithm] $FingerprintHasher, + + [Parameter()] + [AllowNull()] + [pscustomobject] $RemovalWorkState, + + [Parameter()] + [AllowNull()] + [pscustomobject] $EqualityState, + + [Parameter()] + [AllowNull()] + [System.Collections.Generic.Dictionary[int, string]] $EqualityFingerprintCache ) $scalarTags = [System.Collections.Generic.HashSet[string]]::new( @@ -74,7 +86,7 @@ function Test-YamlNodeGraph { $isPairs = $tag -ceq 'tag:yaml.org,2002:pairs' $isOrderedMap = $tag -ceq 'tag:yaml.org,2002:omap' - $orderedKeys = [System.Collections.Generic.HashSet[string]]::new( + $orderedKeys = [System.Collections.Generic.Dictionary[string, object]]::new( [System.StringComparer]::Ordinal ) for ($index = $current.Items.Count - 1; $index -ge 0; $index--) { @@ -91,17 +103,13 @@ function Test-YamlNodeGraph { )) } if ($isOrderedMap) { - $keyFingerprint = Get-YamlNodeFingerprint ` + Assert-YamlNodeKeyUnique ` -Node $entryNode.Entries[0].Key ` - -Active ([System.Collections.Generic.HashSet[int]]::new()) ` - -Cache $FingerprintCache -Hasher $FingerprintHasher - if (-not $orderedKeys.Add($keyFingerprint)) { - $keyNode = $entryNode.Entries[0].Key - throw (New-YamlException -Start $keyNode.Start -End $keyNode.End ` - -ErrorId 'YamlDuplicateKey' -Message ( - 'A duplicate key was found in a YAML ordered mapping.' - )) - } + -Buckets $orderedKeys -FingerprintCache $FingerprintCache ` + -FingerprintHasher $FingerprintHasher ` + -DuplicateMessage 'A duplicate key was found in a YAML ordered mapping.' ` + -RemovalWorkState $RemovalWorkState -EqualityState $EqualityState ` + -EqualityFingerprintCache $EqualityFingerprintCache } } $stack.Push($item) @@ -120,20 +128,16 @@ function Test-YamlNodeGraph { )) } - $keys = [System.Collections.Generic.HashSet[string]]::new( + $keys = [System.Collections.Generic.Dictionary[string, object]]::new( [System.StringComparer]::Ordinal ) for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) { $entry = $current.Entries[$index] - $fingerprint = Get-YamlNodeFingerprint -Node $entry.Key ` - -Active ([System.Collections.Generic.HashSet[int]]::new()) ` - -Cache $FingerprintCache -Hasher $FingerprintHasher - if (-not $keys.Add($fingerprint)) { - throw (New-YamlException -Start $entry.Key.Start -End $entry.Key.End ` - -ErrorId 'YamlDuplicateKey' -Message ( - 'A duplicate mapping key is not allowed.' - )) - } + Assert-YamlNodeKeyUnique -Node $entry.Key -Buckets $keys ` + -FingerprintCache $FingerprintCache -FingerprintHasher $FingerprintHasher ` + -DuplicateMessage 'A duplicate mapping key is not allowed.' ` + -RemovalWorkState $RemovalWorkState -EqualityState $EqualityState ` + -EqualityFingerprintCache $EqualityFingerprintCache if ($tag -ceq 'tag:yaml.org,2002:set') { $setValue = $entry.Value diff --git a/src/functions/public/Remove-YamlEntry.ps1 b/src/functions/public/Remove-YamlEntry.ps1 new file mode 100644 index 0000000..384413e --- /dev/null +++ b/src/functions/public/Remove-YamlEntry.ps1 @@ -0,0 +1,288 @@ +function Remove-YamlEntry { + <# + .SYNOPSIS + Removes entries from a YAML representation graph by JSON Pointer. + + .DESCRIPTION + Aggregates input strings with a line feed, parses them as one YAML stream, + deep-clones the representation graph, resolves every RFC 6901 JSON Pointer + against that unchanged clone, and emits one deterministic YAML string after + applying the complete removal transaction. + + Mapping tokens address only scalar keys whose effective YAML tag is string + and whose scalar content is an ordinal match. Complex, non-string, and + unknown-tagged keys remain intact and are never coerced. Sequence tokens + must be canonical non-negative decimal indexes. Tilde escapes are strict: + ~0 decodes to tilde and ~1 decodes to slash. + + Aliases are traversed by node identity. Removing inside a shared collection + changes every alias to that collection, while targeting an alias edge removes + only that parent edge. Duplicate logical targets are coalesced, ancestors + subsume descendants, and sequence entries are removed by descending original + index. + + Output preserves unaffected tags, anchors, aliases, shared and cyclic graph + identity, complex keys, mapping order, and document order. It uses LF line + endings, has no final newline, and explicitly starts every remaining + document. + + .PARAMETER InputObject + YAML text. Multiple array elements or pipeline records are joined with a + line feed and parsed as one YAML stream. + + .PARAMETER Path + One or more RFC 6901 JSON Pointers. An empty string selects a document root. + Every non-empty pointer must start with slash. + + .PARAMETER DocumentIndex + Zero-based document index to modify. The default is 0. + + .PARAMETER AllDocuments + Applies every path independently to every document in the original stream. + + .PARAMETER IgnoreMissing + Skips unresolved document and path combinations. Invalid pointer syntax, + invalid sequence index tokens, ambiguous matches, and an unavailable + DocumentIndex still terminate. + + .PARAMETER Indent + Block indentation from 2 through 9 spaces. The default is 2. + + .PARAMETER Depth + Maximum YAML node nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum YAML nodes in the input and result, and the invocation-wide ceiling + applied independently to clone creation, removal work, and output validation. + The default is 100000. + + .PARAMETER MaxAliases + Maximum aliases in the input and result. The default is 1000. + + .PARAMETER MaxScalarLength + Maximum decoded character count for one scalar. The default is 1048576. + + .PARAMETER MaxTagLength + Maximum expanded character count for one tag. The default is 1024. + + .PARAMETER MaxTotalTagLength + Maximum cumulative expanded tag characters. The default is 65536. + + .PARAMETER MaxNumericLength + Maximum digits in an implicitly or explicitly typed number. The default is + 4096. + + .EXAMPLE + Get-Content -Path '.\config.yaml' | + Remove-YamlEntry -Path '/service/obsolete' + + Aggregates file lines and removes one nested mapping entry from document zero. + + .EXAMPLE + $clean = Remove-YamlEntry -InputObject $yaml -Path @('/metadata/identifier', '/items/2') + + Resolves both paths against the original graph, then applies them atomically. + + .EXAMPLE + Remove-YamlEntry $stream '/temporary' -AllDocuments -IgnoreMissing -Indent 4 + + Removes the key where it exists in every document and emits four-space YAML. + + .EXAMPLE + Remove-YamlEntry $stream '' -DocumentIndex 1 + + Removes the second YAML document from the stream. + + .INPUTS + System.String[] + + .OUTPUTS + System.String + + .LINK + https://github.com/PSModule/Yaml#remove-yaml-entries + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Transforms an isolated in-memory YAML graph and returns text.' + )] + [OutputType([string])] + [CmdletBinding(DefaultParameterSetName = 'Document')] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $InputObject, + + [Parameter(Mandatory, Position = 1)] + [AllowEmptyString()] + [string[]] $Path, + + [Parameter(ParameterSetName = 'Document')] + [ValidateRange(0, 2147483647)] + [int] $DocumentIndex = 0, + + [Parameter(Mandatory, ParameterSetName = 'AllDocuments')] + [switch] $AllDocuments, + + [Parameter()] + [switch] $IgnoreMissing, + + [Parameter()] + [ValidateRange(2, 9)] + [int] $Indent = 2, + + [Parameter()] + [ValidateRange(1, 128)] + [int] $Depth = 100, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [Parameter()] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + begin { + $lines = [System.Collections.Generic.List[string]]::new() + } + process { + foreach ($line in $InputObject) { + $lines.Add($line) + } + } + end { + $yamlText = $lines -join "`n" + try { + $documentBox = Read-YamlStream -Yaml $yamlText -Depth $Depth ` + -MaxNodes $MaxNodes -MaxAliases $MaxAliases ` + -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength + $sourceDocuments = [object[]] $documentBox.Value + $selectAllDocuments = $AllDocuments.IsPresent + if (-not $selectAllDocuments -and $DocumentIndex -ge $sourceDocuments.Count) { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalDocumentIndexOutOfRange' -Message ( + "YAML document index $DocumentIndex is unavailable; the stream contains " + + "$($sourceDocuments.Count) documents." + )) + } + + $workState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + $parsedPointers = [System.Collections.Generic.List[object]]::new() + foreach ($pointer in $Path) { + $tokens = (ConvertFrom-YamlJsonPointer -Pointer $pointer -State $workState).Value + $parsedPointers.Add([pscustomobject]@{ + Pointer = $pointer + Tokens = [string[]] $tokens + }) + } + + $cloneState = [pscustomobject]@{ + NextId = 1 + CreatedNodes = 0 + MaxNodes = $MaxNodes + } + $resultDocuments = [System.Collections.Generic.List[object]]::new() + $cloneCache = [System.Collections.Generic.Dictionary[int, object]]::new() + try { + foreach ($sourceDocument in $sourceDocuments) { + $resultDocuments.Add(( + Copy-YamlMergeNode -Node $sourceDocument -Cache $cloneCache ` + -State $cloneState + )) + } + } catch { + if ($_.Exception.Data.Contains('YamlErrorId') -and + $_.Exception.Data['YamlErrorId'] -ceq 'YamlMergeNodeLimitExceeded') { + throw (New-YamlRemovalException ` + -ErrorId 'YamlRemovalNodeLimitExceeded' -Message ( + "Cloning the YAML removal graph exceeded the configured limit of " + + "$MaxNodes nodes." + )) + } + throw + } + $resolvedTargets = [System.Collections.Generic.List[object]]::new() + if ($selectAllDocuments) { + for ($currentDocumentIndex = 0; $currentDocumentIndex -lt + $resultDocuments.Count; $currentDocumentIndex++) { + foreach ($parsedPointer in $parsedPointers) { + $resolution = Resolve-YamlRemovalTarget ` + -Document $resultDocuments[$currentDocumentIndex] ` + -DocumentIndex $currentDocumentIndex ` + -Pointer $parsedPointer.Pointer -Tokens $parsedPointer.Tokens ` + -State $workState + if ($resolution.Found) { + $resolvedTargets.Add($resolution) + } elseif (-not $IgnoreMissing) { + throw (New-YamlRemovalException -Node $resolution.Node ` + -ErrorId $resolution.ErrorId -Message $resolution.Message) + } + } + } + } else { + foreach ($parsedPointer in $parsedPointers) { + $resolution = Resolve-YamlRemovalTarget ` + -Document $resultDocuments[$DocumentIndex] ` + -DocumentIndex $DocumentIndex ` + -Pointer $parsedPointer.Pointer -Tokens $parsedPointer.Tokens ` + -State $workState + if ($resolution.Found) { + $resolvedTargets.Add($resolution) + } elseif (-not $IgnoreMissing) { + throw (New-YamlRemovalException -Node $resolution.Node ` + -ErrorId $resolution.ErrorId -Message $resolution.Message) + } + } + } + + $targets = ( + Select-YamlRemovalTarget -Targets ([object[]] $resolvedTargets.ToArray()) ` + -State $workState + ).Value + Remove-YamlRepresentationTarget -Documents $resultDocuments ` + -Targets ([object[]] $targets) -State $workState + $resultArray = [object[]] $resultDocuments.ToArray() + $validationState = [pscustomobject]@{ + Count = 0L + MaxNodes = $MaxNodes + } + Assert-YamlRemovalGraph -Documents $resultArray -Depth $Depth ` + -MaxNodes $MaxNodes -MaxAliases $MaxAliases ` + -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -State $validationState + $result = ConvertTo-YamlRepresentationText -Documents $resultArray -Indent $Indent + Write-Debug "Remove-YamlEntry work operations: $($workState.Count)." + $PSCmdlet.WriteObject($result, $false) + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + $record = New-YamlErrorRecord -Exception $_.Exception ` + -DefaultErrorId 'YamlRemovalFailed' -Category InvalidData ` + -TargetObject $yamlText + $PSCmdlet.ThrowTerminatingError($record) + } + } +} diff --git a/tests/Packaging.Tests.ps1 b/tests/Packaging.Tests.ps1 index 2824f17..ac5e530 100644 --- a/tests/Packaging.Tests.ps1 +++ b/tests/Packaging.Tests.ps1 @@ -135,6 +135,7 @@ Describe 'Generated artifact package' { 'Format-Yaml', 'Import-Yaml', 'Merge-Yaml', + 'Remove-YamlEntry', 'Test-Yaml' ) @($manifest.FileList) | Should -Contain 'Yaml.psm1' @@ -263,4 +264,54 @@ if ($mergedValue['service']['image'] -cne 'example:v2' -or Should -BeFalse Write-Information -MessageData $runtime -InformationAction Continue } + + It 'removes representation entries in a fresh PowerShell 7.6 Core process' ` + -Skip:($skipArtifactTests -or $null -eq (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $script = @' +$ErrorActionPreference = 'Stop' +$ps = $PSVersionTable.PSVersion +if ($ps -lt [version] '7.6') { + throw "Expected PowerShell 7.6 or newer but got $ps." +} +if ($PSVersionTable.PSEdition -cne 'Core') { + throw "Expected PowerShell Core but got $($PSVersionTable.PSEdition)." +} +Import-Module -Name '__MANIFEST__' -Force +$removed = @" +root: &shared + keep: true + drop: false +copy: *shared +"@ | Remove-YamlEntry -Path '/copy/drop' +if ($removed -match "`r" -or $removed.EndsWith("`n", [System.StringComparison]::Ordinal)) { + throw 'The imported removal command did not normalize its output contract.' +} +$value = $removed | ConvertFrom-Yaml -AsHashtable +if ($value['root'].Contains('drop') -or $value['copy'].Contains('drop')) { + throw 'The imported removal command did not mutate the shared mapping.' +} +if (-not [object]::ReferenceEquals($value['root'], $value['copy'])) { + throw 'The imported removal command lost shared mapping identity.' +} +$empty = Remove-YamlEntry "---`nfirst: true`n---`nsecond: true" '' -AllDocuments +if ($empty -cne '') { + throw 'The imported removal command did not remove every selected document.' +} +"remove-runtime=$ps;edition=$($PSVersionTable.PSEdition)" +'@.Replace('__MANIFEST__', $artifactManifestPath.Replace("'", "''")) + + $output = @(& pwsh -NoLogo -NoProfile -Command $script) + $LASTEXITCODE | Should -Be 0 + $runtime = $output | Where-Object { $_ -like 'remove-runtime=*' } | + Select-Object -Last 1 + + $runtimeMatch = [regex]::Match( + [string] $runtime, + '^remove-runtime=(?\d+(?:\.\d+){1,3});edition=Core$' + ) + $runtimeMatch.Success | Should -BeTrue + ([version] $runtimeMatch.Groups['Version'].Value) -lt [version] '7.6' | + Should -BeFalse + Write-Information -MessageData $runtime -InformationAction Continue + } } diff --git a/tests/Remove-YamlEntry.Tests.ps1 b/tests/Remove-YamlEntry.Tests.ps1 new file mode 100644 index 0000000..caa793a --- /dev/null +++ b/tests/Remove-YamlEntry.Tests.ps1 @@ -0,0 +1,962 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') + + function Get-RemoveYamlFailure { + <# + .SYNOPSIS + Captures one expected terminating Remove-YamlEntry error. + #> + param ( + [Parameter(Mandatory)] + [scriptblock] $Action + ) + + try { + $null = & $Action + } catch { + return $_ + } + throw 'The YAML removal unexpectedly succeeded.' + } + + function Invoke-RemoveYamlAmbiguityProbe { + <# + .SYNOPSIS + Creates an otherwise unreachable ambiguous representation mapping. + #> + $implementation = { + $document = (Read-YamlStreamCore -Yaml 'key: value' -Depth 100 -MaxNodes 100 ` + -MaxAliases 100 -MaxScalarLength 1048576 -MaxTagLength 1024 ` + -MaxTotalTagLength 65536 -MaxNumericLength 4096).Value[0] + $document.Entries.Add([pscustomobject]@{ + Key = $document.Entries[0].Key + Value = $document.Entries[0].Value + }) + $state = [pscustomobject]@{ Count = 0L; MaxNodes = 100 } + $tokens = (ConvertFrom-YamlJsonPointer -Pointer '/key' -State $state).Value + Resolve-YamlRemovalTarget -Document $document -DocumentIndex 0 ` + -Pointer '/key' -Tokens $tokens -State $state + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return & $implementation + } + return & $loadedModule $implementation + } + + function Measure-RemoveYamlWork { + <# + .SYNOPSIS + Captures the deterministic removal work count. + #> + param ( + [Parameter(Mandatory)] + [string] $Yaml, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string[]] $Path, + + [Parameter()] + [hashtable] $Parameters = @{} + ) + + $records = @( + Remove-YamlEntry -InputObject $Yaml -Path $Path @Parameters -Debug 5>&1 + ) + $debugText = $records | + ForEach-Object ToString | + Where-Object { $_ -like '*Remove-YamlEntry work operations:*' } | + Select-Object -Last 1 + if ($null -eq $debugText -or + $debugText -notmatch 'Remove-YamlEntry work operations: (?\d+)') { + throw 'Remove-YamlEntry did not report its deterministic work count.' + } + $output = $records | + Where-Object { $_ -isnot [System.Management.Automation.DebugRecord] } | + Select-Object -First 1 + + [pscustomobject]@{ + Output = [string] $output + Count = [long] $Matches.WorkCount + } + } + + function Test-RemoveYamlFingerprintCollision { + <# + .SYNOPSIS + Forces representation-key fingerprint candidates through exact graph equality. + #> + param ( + [Parameter(Mandatory)] + [string] $Yaml + ) + + $implementation = { + param ([string] $YamlText) + + $document = (Read-YamlStreamCore -Yaml $YamlText -Depth 100 -MaxNodes 100 ` + -MaxAliases 100 -MaxScalarLength 1048576 -MaxTagLength 1024 ` + -MaxTotalTagLength 65536 -MaxNumericLength 4096).Value[0] + $fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new() + foreach ($entry in $document.Entries) { + $fingerprintCache[$entry.Key.Id] = 'forced-collision' + } + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + $equalityState = [pscustomobject]@{ + MaxNodes = 100 + FingerprintHasher = $hasher + WorkState = [pscustomobject]@{ Count = 0L; MaxNodes = 100 } + MutationState = [pscustomobject]@{ Version = 0L } + IndexDependents = ( + [System.Collections.Generic.Dictionary[int, object]]::new() + ) + Cache = ( + [System.Collections.Generic.Dictionary[string, bool]]::new( + [System.StringComparer]::Ordinal + ) + ) + InputIndex = 0 + } + $equalityFingerprintCache = ( + [System.Collections.Generic.Dictionary[int, string]]::new() + ) + Test-YamlNodeGraph -Node $document ` + -Visited ([System.Collections.Generic.HashSet[int]]::new()) ` + -FingerprintCache $fingerprintCache -FingerprintHasher $hasher ` + -EqualityState $equalityState ` + -EqualityFingerprintCache $equalityFingerprintCache + } finally { + $hasher.Dispose() + } + return $true + } + + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + return & $implementation $Yaml + } + return & $loadedModule $implementation $Yaml + } +} + +Describe 'Remove-YamlEntry' { + Context 'Public contract' { + It 'exposes one advanced string transformation contract' { + $command = Get-Command -Name Remove-YamlEntry + $inputParameter = $command.Parameters['InputObject'] + $inputAttribute = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + $inputAllowsEmpty = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] } + $pathParameter = $command.Parameters['Path'] + $pathAttribute = $pathParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + $pathAllowsEmpty = $pathParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] } + $documentIndexRange = $command.Parameters['DocumentIndex'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $indentRange = $command.Parameters['Indent'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + $help = Get-Help -Name Remove-YamlEntry -Full + + $command.CmdletBinding | Should -BeTrue + $command.DefaultParameterSet | Should -BeExactly 'Document' + @($command.ParameterSets.Name | Sort-Object) | + Should -Be @('AllDocuments', 'Document') + $command.Parameters.ContainsKey('WhatIf') | Should -BeFalse + $command.Parameters.ContainsKey('Confirm') | Should -BeFalse + $inputParameter.ParameterType | Should -Be ([string[]]) + $inputAttribute.Mandatory | Should -BeTrue + $inputAttribute.Position | Should -Be 0 + $inputAttribute.ValueFromPipeline | Should -BeTrue + $inputAllowsEmpty | Should -Not -BeNullOrEmpty + $pathParameter.ParameterType | Should -Be ([string[]]) + $pathAttribute.Mandatory | Should -BeTrue + $pathAttribute.Position | Should -Be 1 + $pathAllowsEmpty | Should -Not -BeNullOrEmpty + $documentIndexRange.MinRange | Should -Be 0 + $documentIndexRange.MaxRange | Should -Be 2147483647 + $indentRange.MinRange | Should -Be 2 + $indentRange.MaxRange | Should -Be 9 + @($command.OutputType.Type) | Should -Contain ([string]) + $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Description.Text | Should -Match 'JSON Pointer' + @($help.Examples.Example).Count | Should -BeGreaterOrEqual 3 + } + + It 'separates one-document and all-document selection' { + $command = Get-Command -Name Remove-YamlEntry + $documentSet = $command.ParameterSets | + Where-Object Name -EQ 'Document' + $allSet = $command.ParameterSets | + Where-Object Name -EQ 'AllDocuments' + + ($documentSet.Parameters | Where-Object Name -EQ 'DocumentIndex').IsMandatory | + Should -BeFalse + @($documentSet.Parameters.Name) | Should -Not -Contain 'AllDocuments' + ($allSet.Parameters | Where-Object Name -EQ 'AllDocuments').IsMandatory | + Should -BeTrue + @($allSet.Parameters.Name) | Should -Not -Contain 'DocumentIndex' + } + + It 'mirrors every parser safety range' -ForEach @( + @{ Name = 'Depth'; Minimum = 1; Maximum = 128 } + @{ Name = 'MaxNodes'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxAliases'; Minimum = 0; Maximum = 2147483647 } + @{ Name = 'MaxScalarLength'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxTagLength'; Minimum = 1; Maximum = 1048576 } + @{ Name = 'MaxTotalTagLength'; Minimum = 1; Maximum = 2147483647 } + @{ Name = 'MaxNumericLength'; Minimum = 1; Maximum = 1048576 } + ) { + $range = (Get-Command Remove-YamlEntry).Parameters[$Name].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + + $range.MinRange | Should -Be $Minimum + $range.MaxRange | Should -Be $Maximum + } + + It 'aggregates direct and pipeline string records with LF' { + $records = @('root:', ' keep: true', ' drop: false') + $expected = "root:`n keep: true" | Format-Yaml + + (Remove-YamlEntry -InputObject $records -Path '/root/drop') | + Should -BeExactly $expected + ($records | Remove-YamlEntry -Path '/root/drop') | + Should -BeExactly $expected + } + + It 'emits exactly one LF-only string without a final newline' { + $result = @( + Remove-YamlEntry -InputObject "keep: true`r`ndrop: false`r`n" -Path '/drop' + ) + + $result.Count | Should -Be 1 + $result[0] | Should -BeOfType [string] + $result[0] | Should -Not -Match "`r" + $result[0].EndsWith("`n", [System.StringComparison]::Ordinal) | + Should -BeFalse + } + } + + Context 'JSON Pointer and mapping keys' { + It 'addresses empty, numeric-looking, escaped, Unicode, and case-sensitive keys' { + $unicodeKey = [char] 0x00C5 + $yaml = @" +"": empty +"01": numeric-looking +"a/b": slash +"a~b": tilde +"~1": escaped-twice +"$unicodeKey": unicode +Name: upper +name: lower +keep: true +"@ + $actual = Remove-YamlEntry $yaml @( + '/' + '/01' + '/a~1b' + '/a~0b' + '/~01' + "/$unicodeKey" + '/Name' + ) + + $actual | Should -BeExactly ("name: lower`nkeep: true" | Format-Yaml) + } + + It 'matches mapping key code points ordinally' { + $composed = [string] [char] 0x00E9 + $decomposed = 'e' + [char] 0x0301 + $yaml = '"' + $decomposed + '": value' + "`nkeep: true" + + Remove-YamlEntry $yaml ('/' + $composed) -IgnoreMissing | + Should -BeExactly ($yaml | Format-Yaml) + + $result = Remove-YamlEntry $yaml ('/' + $decomposed) | + ConvertFrom-Yaml -AsHashtable + $result.Contains($decomposed) | Should -BeFalse + $result['keep'] | Should -BeTrue + } + + It 'does not treat a linguistically equal unknown tag as a string tag' { + $yaml = "! key: tagged`nkeep: true" + + Remove-YamlEntry $yaml '/key' -IgnoreMissing | + Should -BeExactly ($yaml | Format-Yaml) + } + + It 'treats explicit string keys as strings and plain numeric keys as numbers' { + $yaml = @' +1: numeric +!!str 1: string +'@ + + (Remove-YamlEntry $yaml '/1') | + Should -BeExactly ('1: numeric' | Format-Yaml) + } + + It 'rejects pointers that do not start with slash' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'key: value' 'key' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalInvalidPointer' + $failure.FullyQualifiedErrorId | + Should -Be 'YamlRemovalInvalidPointer,Remove-YamlEntry' + } + + It 'rejects every malformed tilde escape' -ForEach @( + @{ Pointer = '/key~' } + @{ Pointer = '/key~2' } + @{ Pointer = '/key~x' } + @{ Pointer = '/key~~0' } + ) { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'key: value' $Pointer -IgnoreMissing + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalInvalidPointerEscape' + } + + It 'does not guess among ambiguous matching string keys' { + $failure = Get-RemoveYamlFailure { + Invoke-RemoveYamlAmbiguityProbe + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalAmbiguousTarget' + } + + It 'preserves complex, non-string, and unknown-tagged keys as unaddressable' { + $yaml = @' +? [complex] +: sequence-key +2: numeric-key +!key tagged: tagged-key +keep: true +'@ + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/tagged' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + $failure.Exception.Message | Should -Match 'cannot be addressed' + (Remove-YamlEntry $yaml '/tagged' -IgnoreMissing) | + Should -BeExactly ($yaml | Format-Yaml) + } + } + + Context 'Sequence and nested traversal' { + It 'removes nested mapping and sequence entries' { + $yaml = @' +root: + items: + - keep: zero + drop: zero + - keep: one + drop: one +'@ + $expected = @' +root: + items: + - keep: zero + drop: zero + - keep: one +'@ | Format-Yaml + + (Remove-YamlEntry $yaml '/root/items/1/drop') | + Should -BeExactly $expected + } + + It 'accepts only canonical non-negative decimal sequence indexes' -ForEach @( + @{ Token = '-' } + @{ Token = '+1' } + @{ Token = '-1' } + @{ Token = '01' } + @{ Token = '1.0' } + @{ Token = '2147483648' } + ) { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry '[zero, one]' "/$Token" -IgnoreMissing + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalInvalidSequenceIndex' + } + + It 'treats out-of-range sequence indexes as missing' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry '[zero, one]' '/2' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + (Remove-YamlEntry '[zero, one]' '/2' -IgnoreMissing) | + Should -BeExactly ('[zero, one]' | Format-Yaml) + } + + It 'treats traversal through a scalar as missing' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'root: scalar' '/root/child' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + } + } + + Context 'Missing targets and transactions' { + It 'terminates before emitting changes when any path is missing' { + $output = @() + try { + $output = @( + Remove-YamlEntry 'first: 1' @('/first', '/missing') + ) + throw 'The YAML removal unexpectedly succeeded.' + } catch { + $failure = $_ + } + + $output.Count | Should -Be 0 + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + } + + It 'skips only unresolved targets with IgnoreMissing' { + $actual = Remove-YamlEntry 'first: 1' @('/missing', '/first') -IgnoreMissing + + $actual | Should -BeExactly ('{}' | Format-Yaml) + } + + It 'is idempotent when repeated with IgnoreMissing' { + $first = Remove-YamlEntry 'keep: true' '/drop' -IgnoreMissing + $second = Remove-YamlEntry $first '/drop' -IgnoreMissing + + $second | Should -BeExactly $first + } + } + + Context 'Document selection and root removal' { + It 'uses zero-based document index zero by default' { + $yaml = "---`ndrop: first`nkeep: one`n---`ndrop: second`nkeep: two" + $documents = @( + Remove-YamlEntry $yaml '/drop' | + ConvertFrom-Yaml -AsHashtable + ) + + $documents.Count | Should -Be 2 + $documents[0].Contains('drop') | Should -BeFalse + $documents[1]['drop'] | Should -Be 'second' + } + + It 'treats an explicitly false AllDocuments switch as default nested selection' { + $yaml = @' +--- +root: + drop: first + keep: one +--- +root: + drop: second + keep: two +'@ + $defaultResult = Remove-YamlEntry $yaml '/root/drop' + $falseResult = Remove-YamlEntry $yaml '/root/drop' -AllDocuments:$false + $allResult = Remove-YamlEntry $yaml '/root/drop' -AllDocuments + $defaultDocuments = @($defaultResult | ConvertFrom-Yaml -AsHashtable) + $allDocuments = @($allResult | ConvertFrom-Yaml -AsHashtable) + + $falseResult | Should -BeExactly $defaultResult + $defaultDocuments[0]['root'].Contains('drop') | Should -BeFalse + $defaultDocuments[1]['root']['drop'] | Should -Be 'second' + $allDocuments[0]['root'].Contains('drop') | Should -BeFalse + $allDocuments[1]['root'].Contains('drop') | Should -BeFalse + } + + It 'selects one explicit document index' { + $yaml = "---`ndrop: first`n---`ndrop: second" + $documents = @( + Remove-YamlEntry $yaml '/drop' -DocumentIndex 1 | + ConvertFrom-Yaml -AsHashtable + ) + + $documents[0]['drop'] | Should -Be 'first' + $documents[1].Contains('drop') | Should -BeFalse + } + + It 'rejects an unavailable document index even with IgnoreMissing' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry 'key: value' '/key' -DocumentIndex 1 -IgnoreMissing + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalDocumentIndexOutOfRange' + } + + It 'requires each path in each document unless IgnoreMissing is used' { + $yaml = "---`ndrop: first`n---`nkeep: second" + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/drop' -AllDocuments + } + $documents = @( + Remove-YamlEntry $yaml '/drop' -AllDocuments -IgnoreMissing | + ConvertFrom-Yaml -AsHashtable + ) + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalPathNotFound' + $documents[0].Contains('drop') | Should -BeFalse + $documents[1]['keep'] | Should -Be 'second' + } + + It 'removes one selected document with an empty pointer' { + $yaml = "---`nfirst: true`n---`nsecond: true`n---`nthird: true" + $documents = @( + Remove-YamlEntry $yaml '' -DocumentIndex 1 | + ConvertFrom-Yaml -AsHashtable + ) + + $documents.Count | Should -Be 2 + $documents[0]['first'] | Should -BeTrue + $documents[1]['third'] | Should -BeTrue + } + + It 'treats an explicitly false AllDocuments switch as default root selection' { + $yaml = "---`nfirst: true`n---`nsecond: true" + $defaultResult = Remove-YamlEntry $yaml '' + $falseResult = Remove-YamlEntry $yaml '' -AllDocuments:$false + $allResult = Remove-YamlEntry $yaml '' -AllDocuments + $defaultDocuments = @($defaultResult | ConvertFrom-Yaml -AsHashtable) + + $falseResult | Should -BeExactly $defaultResult + $defaultDocuments.Count | Should -Be 1 + $defaultDocuments[0]['second'] | Should -BeTrue + $allResult | Should -BeExactly '' + } + + It 'removes all documents with AllDocuments and an empty pointer' { + $result = @( + Remove-YamlEntry "---`nfirst: true`n---`nsecond: true" '' -AllDocuments + ) + + $result.Count | Should -Be 1 + $result[0] | Should -BeExactly '' + } + } + + Context 'Multiple-path ordering and coalescing' { + It 'coalesces duplicate targets' { + (Remove-YamlEntry 'keep: true' @('/keep', '/keep', '/keep')) | + Should -BeExactly ('{}' | Format-Yaml) + } + + It 'lets an ancestor removal subsume its descendant' { + $yaml = "root:`n child: value`nkeep: true" + + (Remove-YamlEntry $yaml @('/root/child', '/root')) | + Should -BeExactly ('keep: true' | Format-Yaml) + } + + It 'removes original sequence indexes in descending order' { + (Remove-YamlEntry '[zero, one, two, three, four]' @('/1', '/3')) | + Should -BeExactly ('[zero, two, four]' | Format-Yaml) + } + + It 'preserves mapping order among surviving entries' { + $yaml = "first: 1`nsecond: 2`nthird: 3`nfourth: 4" + $expected = "first: 1`nthird: 3" | Format-Yaml + + (Remove-YamlEntry $yaml @('/fourth', '/second')) | + Should -BeExactly $expected + } + + It 'coalesces one shared target reached through direct and alias paths' { + $yaml = @' +root: &shared + drop: true + keep: true +copy: *shared +'@ + $result = Remove-YamlEntry $yaml @('/root/drop', '/copy/drop') | + ConvertFrom-Yaml -AsHashtable + + $result['root'].Contains('drop') | Should -BeFalse + $result['copy'].Contains('drop') | Should -BeFalse + [object]::ReferenceEquals($result['root'], $result['copy']) | + Should -BeTrue + } + + It 'keeps an independently requested shared mutation when one alias ancestor is removed' { + $yaml = @' +root: &shared + drop: true + keep: true +copy: *shared +'@ + $result = Remove-YamlEntry $yaml @('/copy', '/root/drop') | + ConvertFrom-Yaml -AsHashtable + + $result.Contains('copy') | Should -BeFalse + $result['root'].Contains('drop') | Should -BeFalse + } + + It 'orders shared mapping removals by original index across pointer depths' { + $yaml = @' +a: &shared + x: 1 + y: 2 +b: + c: *shared +'@ + $result = Remove-YamlEntry $yaml @('/a/y', '/b/c/x') | + ConvertFrom-Yaml -AsHashtable + + $result['a'].Count | Should -Be 0 + [object]::ReferenceEquals($result['a'], $result['b']['c']) | + Should -BeTrue + } + + It 'orders shared sequence removals by original index across pointer depths' { + $yaml = "a: &shared [zero, one, two]`nb:`n c: *shared" + $result = Remove-YamlEntry $yaml @('/a/2', '/b/c/0') | + ConvertFrom-Yaml -AsHashtable + + @($result['a']) | Should -Be @('one') + [object]::ReferenceEquals($result['a'], $result['b']['c']) | + Should -BeTrue + } + } + + Context 'Aliases, sharing, and cycles' { + It 'mutates a shared mapping through an alias path' { + $yaml = @' +root: &shared + keep: true + drop: false +copy: *shared +'@ + $result = Remove-YamlEntry $yaml '/copy/drop' | + ConvertFrom-Yaml -AsHashtable + + $result['root'].Contains('drop') | Should -BeFalse + $result['copy'].Contains('drop') | Should -BeFalse + [object]::ReferenceEquals($result['root'], $result['copy']) | + Should -BeTrue + } + + It 'removes only an alias edge when that edge is the target' { + $yaml = @' +root: &shared + keep: true +copy: *shared +'@ + $result = Remove-YamlEntry $yaml '/copy' | + ConvertFrom-Yaml -AsHashtable + + $result.Contains('copy') | Should -BeFalse + $result['root']['keep'] | Should -BeTrue + } + + It 'traverses a recursive graph safely and preserves its identity' { + $yaml = @' +node: &node + self: *node + keep: true + drop: false +'@ + $result = Remove-YamlEntry $yaml '/node/self/self/drop' | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('drop') | Should -BeFalse + [object]::ReferenceEquals($result['node'], $result['node']['self']) | + Should -BeTrue + } + + It 'can remove the alias edge that closes a cycle' { + $yaml = @' +node: &node + self: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml '/node/self' | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + + It 'removes an edge that repeats in its own direct cyclic path' { + $yaml = @' +node: &node + self: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml '/node/self/self' | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + + It 'removes an edge that repeats through a longer cycle' { + $yaml = @' +first: &first + next: &second + back: *first + keep: true +'@ + $result = Remove-YamlEntry $yaml '/first/next/back/next/back' | + ConvertFrom-Yaml -AsHashtable + + $result['first']['next'].Contains('back') | Should -BeFalse + $result['first']['next']['keep'] | Should -BeTrue + } + + It 'does not mutually subsume targets reached through cyclic branches' { + $yaml = @' +node: &node + a: *node + b: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml @('/node/a/b', '/node/b/a') | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('a') | Should -BeFalse + $result['node'].Contains('b') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + + It 'coalesces one cyclic target reached through duplicate aliases' { + $yaml = @' +node: &node + self: *node + keep: true +copy: *node +'@ + $result = Remove-YamlEntry $yaml @('/node/self/self', '/copy/self/self') | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + [object]::ReferenceEquals($result['node'], $result['copy']) | + Should -BeTrue + } + + It 'subsumes a cyclic descendant only under a proper requested ancestor' { + $yaml = @' +node: &node + self: *node + keep: true +'@ + $result = Remove-YamlEntry $yaml @('/node/self', '/node/self/self/keep') | + ConvertFrom-Yaml -AsHashtable + + $result['node'].Contains('self') | Should -BeFalse + $result['node']['keep'] | Should -BeTrue + } + } + + Context 'Tags and resulting graph validation' { + It 'preserves unaffected explicit and unknown tags' { + $yaml = @' +root: !item + keep: !!str value + drop: false +'@ + $actual = Remove-YamlEntry $yaml '/root/drop' + + $actual | Should -Match '!item' + $actual | Should -Match '!!str' + $actual | Test-Yaml | Should -BeTrue + } + + It 'rejects removals that invalidate a tagged collection shape' { + $yaml = '!!pairs [ { key: value } ]' + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/0/key' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlInvalidTaggedCollection' + } + + It 'rejects post-removal duplicate representation keys' -ForEach @( + @{ + Yaml = @' +cycle: &cycle { self: *cycle } +shared: &shared { x: 1, y: 2 } +? *shared +: first +? { x: 1 } +: second +'@ + } + @{ + Yaml = @' +shared: &shared { x: 1, y: 2 } +set: !!set + ? *shared + ? { x: 1 } +'@ + } + @{ + Yaml = @' +shared: &shared { x: 1, y: 2 } +ordered: !!omap + - ? *shared + : first + - ? { x: 1 } + : second +'@ + } + ) { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $Yaml '/shared/y' + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlDuplicateKey' + $failure.FullyQualifiedErrorId | + Should -Be 'YamlDuplicateKey,Remove-YamlEntry' + } + + It 'confirms fingerprint candidates with exact graph equality' -ForEach @( + @{ + Yaml = @' +? { x: 1 } +: first +? { x: 2 } +: second +'@ + } + @{ + Yaml = @' +!!str key: string +! key: tagged +'@ + } + ) { + Test-RemoveYamlFingerprintCollision -Yaml $Yaml | Should -BeTrue + } + } + + Context 'Validation and work limits' { + It 'preserves every parser resource classification' -ForEach @( + @{ Yaml = "a:`n b:`n c: value"; Parameters = @{ Depth = 2 } } + @{ Yaml = '[one, two]'; Parameters = @{ MaxNodes = 2 } } + @{ Yaml = "a: &a value`nb: *a"; Parameters = @{ MaxAliases = 0 } } + @{ Yaml = 'value: long'; Parameters = @{ MaxScalarLength = 4 } } + @{ Yaml = '!long value'; Parameters = @{ MaxTagLength = 2 } } + @{ + Yaml = "!a one`n---`n!b two" + Parameters = @{ MaxTotalTagLength = 3 } + } + @{ Yaml = '123'; Parameters = @{ MaxNumericLength = 2 } } + ) { + $parseFailure = Get-RemoveYamlFailure { + $Yaml | Format-Yaml @Parameters + } + $removeFailure = Get-RemoveYamlFailure { + Remove-YamlEntry $Yaml '' @Parameters + } + + $removeFailure.Exception.Data['YamlErrorId'] | + Should -BeExactly $parseFailure.Exception.Data['YamlErrorId'] + $removeFailure.FullyQualifiedErrorId | + Should -Be "$($parseFailure.Exception.Data['YamlErrorId']),Remove-YamlEntry" + } + + It 'bounds pointer parsing with the invocation work budget' { + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry '{}' '/abcdefghij' -IgnoreMissing -MaxNodes 5 + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalWorkLimitExceeded' + $failure.Exception.Data['YamlRemovalWorkLimit'] | Should -Be 5 + } + + It 'bounds duplicate-key graph equality with removal error classification' { + $pairs = 0..9 | ForEach-Object { " key$($_): $($_)" } + $shared = @('shared: &shared') + $pairs + ' drop: true' + $literal = ($pairs | ForEach-Object Trim) -join ', ' + $yaml = (@($shared) + '? *shared' + ': first' + + "? { $literal }" + ': second') -join "`n" + $failure = Get-RemoveYamlFailure { + Remove-YamlEntry $yaml '/shared/drop' -MaxNodes 80 + } + + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlRemovalWorkLimitExceeded' + $failure.FullyQualifiedErrorId | + Should -Be 'YamlRemovalWorkLimitExceeded,Remove-YamlEntry' + $failure.Exception.Data['YamlRemovalWorkLimit'] | Should -Be 80 + $failure.Exception.Data['YamlRemovalWorkOperation'] | + Should -BeExactly 'duplicate-key graph comparison' + $failure.Exception.Message | + Should -Match 'duplicate-key graph comparison' + } + + It 'reports deterministic work and produces a result within budget' { + $measurement = Measure-RemoveYamlWork ` + -Yaml "root:`n first: 1`n second: 2`n third: 3" ` + -Path @('/root/first', '/root/third') ` + -Parameters @{ MaxNodes = 1000 } + + $measurement.Count | Should -BeGreaterThan 0 + $measurement.Count | Should -BeLessOrEqual 1000 + $measurement.Output | + Should -BeExactly ("root:`n second: 2" | Format-Yaml) + } + + It 'applies clone, removal, and output budgets independently' { + $items = 0..29 | ForEach-Object { "item$_" } + $yaml = '[' + ($items -join ', ') + ']' + + $result = Remove-YamlEntry $yaml '/0' -MaxNodes 50 | + ConvertFrom-Yaml + + @($result).Count | Should -Be 29 + $result[0] | Should -BeExactly 'item1' + } + } + + Context 'Deterministic representation output' { + It 'is deterministic, normalized, self-parsing, and leaves input semantics unchanged' { + $yaml = @' +root: &root + first: 1 + second: [two, three] +copy: *root +'@ + $before = $yaml | Format-Yaml -Indent 4 + $first = Remove-YamlEntry $yaml @('/root/first', '/copy/second/0') -Indent 4 + $second = Remove-YamlEntry $yaml @('/root/first', '/copy/second/0') -Indent 4 + + $second | Should -BeExactly $first + ($yaml | Format-Yaml -Indent 4) | Should -BeExactly $before + $first | Test-Yaml | Should -BeTrue + $first | Should -BeExactly ($first | Format-Yaml -Indent 4) + } + } +}