Skip to content

Commit 5eeba68

Browse files
Add representation-preserving YAML merging
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent b9c1ab1 commit 5eeba68

11 files changed

Lines changed: 1652 additions & 0 deletions
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
function Assert-YamlMergeGraph {
2+
<#
3+
.SYNOPSIS
4+
Validates the merged representation graph and its resource budgets.
5+
#>
6+
[CmdletBinding()]
7+
param (
8+
[Parameter(Mandatory)]
9+
[AllowEmptyCollection()]
10+
[object[]] $Documents,
11+
12+
[Parameter(Mandatory)]
13+
[int] $Depth,
14+
15+
[Parameter(Mandatory)]
16+
[int] $MaxNodes,
17+
18+
[Parameter(Mandatory)]
19+
[int] $MaxAliases,
20+
21+
[Parameter(Mandatory)]
22+
[int] $MaxScalarLength,
23+
24+
[Parameter(Mandatory)]
25+
[int] $MaxTagLength,
26+
27+
[Parameter(Mandatory)]
28+
[int] $MaxTotalTagLength
29+
)
30+
31+
$visited = [System.Collections.Generic.HashSet[int]]::new()
32+
$pending = [System.Collections.Generic.Stack[object]]::new()
33+
foreach ($document in $Documents) {
34+
$pending.Push([pscustomobject]@{ Node = $document; Depth = 1 })
35+
}
36+
$nodeCount = 0
37+
$aliasCount = 0
38+
$totalTagLength = 0L
39+
40+
while ($pending.Count -gt 0) {
41+
$item = $pending.Pop()
42+
$node = $item.Node
43+
if (-not $visited.Add($node.Id)) {
44+
continue
45+
}
46+
$nodeCount++
47+
if ($nodeCount -gt $MaxNodes) {
48+
throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeNodeLimitExceeded' -Message (
49+
"The merged YAML graph exceeds the configured limit of $MaxNodes nodes."
50+
))
51+
}
52+
if ($item.Depth -gt $Depth) {
53+
throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeDepthLimitExceeded' -Message (
54+
"The merged YAML graph exceeds the configured depth of $Depth."
55+
))
56+
}
57+
58+
if ($node.Kind -eq 'Alias') {
59+
$aliasCount++
60+
if ($aliasCount -gt $MaxAliases) {
61+
throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeAliasLimitExceeded' -Message (
62+
"The merged YAML graph exceeds the configured limit of $MaxAliases aliases."
63+
))
64+
}
65+
$pending.Push([pscustomobject]@{ Node = $node.Target; Depth = $item.Depth })
66+
continue
67+
}
68+
69+
$tagLength = ([string] $node.Tag).Length
70+
if ($tagLength -gt $MaxTagLength) {
71+
throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeTagLimitExceeded' -Message (
72+
"A tag in the merged YAML graph exceeds the configured limit of $MaxTagLength characters."
73+
))
74+
}
75+
$totalTagLength += $tagLength
76+
if ($totalTagLength -gt $MaxTotalTagLength) {
77+
throw (New-YamlMergeException -Node $node -ErrorId 'YamlMergeTagLimitExceeded' -Message (
78+
"The merged YAML graph exceeds the configured cumulative tag limit of " +
79+
"$MaxTotalTagLength characters."
80+
))
81+
}
82+
83+
if ($node.Kind -eq 'Scalar') {
84+
if ((Get-YamlRuneCount -Text ([string] $node.Value)) -gt $MaxScalarLength) {
85+
throw (New-YamlMergeException -Node $node `
86+
-ErrorId 'YamlMergeScalarLimitExceeded' -Message (
87+
"A scalar in the merged YAML graph exceeds the configured limit of " +
88+
"$MaxScalarLength characters."
89+
))
90+
}
91+
continue
92+
}
93+
if ($node.Kind -eq 'Sequence') {
94+
for ($index = $node.Items.Count - 1; $index -ge 0; $index--) {
95+
$pending.Push([pscustomobject]@{
96+
Node = $node.Items[$index]
97+
Depth = $item.Depth + 1
98+
})
99+
}
100+
continue
101+
}
102+
for ($index = $node.Entries.Count - 1; $index -ge 0; $index--) {
103+
$pending.Push([pscustomobject]@{
104+
Node = $node.Entries[$index].Value
105+
Depth = $item.Depth + 1
106+
})
107+
$pending.Push([pscustomobject]@{
108+
Node = $node.Entries[$index].Key
109+
Depth = $item.Depth + 1
110+
})
111+
}
112+
}
113+
114+
$fingerprintCache = [System.Collections.Generic.Dictionary[int, string]]::new()
115+
$fingerprintHasher = [System.Security.Cryptography.SHA256]::Create()
116+
try {
117+
foreach ($document in $Documents) {
118+
Test-YamlNodeGraph -Node $document `
119+
-Visited ([System.Collections.Generic.HashSet[int]]::new()) `
120+
-FingerprintCache $fingerprintCache -FingerprintHasher $fingerprintHasher
121+
}
122+
} finally {
123+
$fingerprintHasher.Dispose()
124+
}
125+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
function Copy-YamlMergeNode {
2+
<#
3+
.SYNOPSIS
4+
Deep-clones a YAML representation graph with identity memoization.
5+
#>
6+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
7+
'PSUseShouldProcessForStateChangingFunctions', '',
8+
Justification = 'Constructs an isolated in-memory representation graph.'
9+
)]
10+
[CmdletBinding()]
11+
[OutputType([pscustomobject])]
12+
param (
13+
[Parameter(Mandatory)]
14+
[pscustomobject] $Node,
15+
16+
[Parameter(Mandatory)]
17+
[AllowEmptyCollection()]
18+
[System.Collections.Generic.Dictionary[int, object]] $Cache,
19+
20+
[Parameter(Mandatory)]
21+
[pscustomobject] $State
22+
)
23+
24+
if ($Cache.ContainsKey($Node.Id)) {
25+
Write-Output -InputObject $Cache[$Node.Id] -NoEnumerate
26+
return
27+
}
28+
29+
$result = [pscustomobject]@{ Value = $null }
30+
$stack = [System.Collections.Generic.Stack[object]]::new()
31+
$stack.Push([pscustomobject]@{
32+
Source = $Node
33+
Holder = $result
34+
Target = $null
35+
Child = $null
36+
Key = $null
37+
Index = 0
38+
State = 'Start'
39+
})
40+
41+
while ($stack.Count -gt 0) {
42+
$frame = $stack.Peek()
43+
if ($frame.State -eq 'Start') {
44+
if ($Cache.ContainsKey($frame.Source.Id)) {
45+
$frame.Holder.Value = $Cache[$frame.Source.Id]
46+
[void] $stack.Pop()
47+
continue
48+
}
49+
50+
$State.CreatedNodes++
51+
if ($State.CreatedNodes -gt $State.MaxNodes) {
52+
throw (New-YamlMergeException -Node $frame.Source `
53+
-ErrorId 'YamlMergeNodeLimitExceeded' -Message (
54+
"Cloning the merged YAML graph exceeded the configured limit of $($State.MaxNodes) nodes."
55+
))
56+
}
57+
$target = New-YamlNode -Id $State.NextId -Kind $frame.Source.Kind `
58+
-Start $frame.Source.Start -End $frame.Source.End
59+
$State.NextId++
60+
$target.Tag = [string] $frame.Source.Tag
61+
$target.HasUnknownTag = [bool] $frame.Source.HasUnknownTag
62+
$target.Anchor = [string] $frame.Source.Anchor
63+
$target.Value = $frame.Source.Value
64+
$target.Style = [string] $frame.Source.Style
65+
$target.IsPlainImplicit = [bool] $frame.Source.IsPlainImplicit
66+
$target.IsQuotedImplicit = [bool] $frame.Source.IsQuotedImplicit
67+
$target.MaxNumericLength = [int] $frame.Source.MaxNumericLength
68+
$Cache[$frame.Source.Id] = $target
69+
$frame.Target = $target
70+
$frame.Holder.Value = $target
71+
72+
if ($frame.Source.Kind -eq 'Scalar') {
73+
[void] $stack.Pop()
74+
} elseif ($frame.Source.Kind -eq 'Alias') {
75+
$frame.Child = [pscustomobject]@{ Value = $null }
76+
$frame.State = 'Alias'
77+
$stack.Push([pscustomobject]@{
78+
Source = $frame.Source.Target
79+
Holder = $frame.Child
80+
Target = $null
81+
Child = $null
82+
Key = $null
83+
Index = 0
84+
State = 'Start'
85+
})
86+
} elseif ($frame.Source.Kind -eq 'Sequence') {
87+
$frame.State = 'Sequence'
88+
} else {
89+
$frame.State = 'MappingKey'
90+
}
91+
continue
92+
}
93+
94+
if ($frame.State -eq 'Alias') {
95+
$frame.Target.Target = $frame.Child.Value
96+
[void] $stack.Pop()
97+
continue
98+
}
99+
if ($frame.State -eq 'Sequence') {
100+
if ($frame.Index -ge $frame.Source.Items.Count) {
101+
[void] $stack.Pop()
102+
continue
103+
}
104+
$frame.Child = [pscustomobject]@{ Value = $null }
105+
$frame.State = 'SequenceValue'
106+
$stack.Push([pscustomobject]@{
107+
Source = $frame.Source.Items[$frame.Index]
108+
Holder = $frame.Child
109+
Target = $null
110+
Child = $null
111+
Key = $null
112+
Index = 0
113+
State = 'Start'
114+
})
115+
continue
116+
}
117+
if ($frame.State -eq 'SequenceValue') {
118+
$frame.Target.Items.Add($frame.Child.Value)
119+
$frame.Index++
120+
$frame.State = 'Sequence'
121+
continue
122+
}
123+
if ($frame.State -eq 'MappingKey') {
124+
if ($frame.Index -ge $frame.Source.Entries.Count) {
125+
[void] $stack.Pop()
126+
continue
127+
}
128+
$frame.Child = [pscustomobject]@{ Value = $null }
129+
$frame.State = 'MappingKeyValue'
130+
$stack.Push([pscustomobject]@{
131+
Source = $frame.Source.Entries[$frame.Index].Key
132+
Holder = $frame.Child
133+
Target = $null
134+
Child = $null
135+
Key = $null
136+
Index = 0
137+
State = 'Start'
138+
})
139+
continue
140+
}
141+
if ($frame.State -eq 'MappingKeyValue') {
142+
$frame.Key = $frame.Child.Value
143+
$frame.Child = [pscustomobject]@{ Value = $null }
144+
$frame.State = 'MappingValue'
145+
$stack.Push([pscustomobject]@{
146+
Source = $frame.Source.Entries[$frame.Index].Value
147+
Holder = $frame.Child
148+
Target = $null
149+
Child = $null
150+
Key = $null
151+
Index = 0
152+
State = 'Start'
153+
})
154+
continue
155+
}
156+
if ($frame.State -eq 'MappingValue') {
157+
$frame.Target.Entries.Add([pscustomobject]@{
158+
Key = $frame.Key
159+
Value = $frame.Child.Value
160+
})
161+
$frame.Index++
162+
$frame.State = 'MappingKey'
163+
}
164+
}
165+
166+
Write-Output -InputObject $result.Value -NoEnumerate
167+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
function Get-YamlMergeFingerprint {
2+
<#
3+
.SYNOPSIS
4+
Creates a deterministic candidate index for structural merge comparisons.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([string])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[pscustomobject] $Node,
11+
12+
[Parameter(Mandatory)]
13+
[pscustomobject] $State
14+
)
15+
16+
$effective = Get-YamlMergeNode -Node $Node
17+
$tag = Get-YamlMergeNodeTag -Node $effective
18+
if ($effective.Kind -eq 'Scalar') {
19+
$resolved = (Resolve-YamlScalar -Node $effective).Value
20+
$value = Get-YamlScalarFingerprint -Value $resolved -Hasher $State.FingerprintHasher
21+
return 'scalar:{0}:{1}:{2}' -f $tag.Length, $tag, $value
22+
}
23+
24+
$count = if ($effective.Kind -eq 'Sequence') {
25+
$effective.Items.Count
26+
} else {
27+
$effective.Entries.Count
28+
}
29+
return '{0}:{1}:{2}:{3}' -f $effective.Kind.ToLowerInvariant(), $tag.Length, $tag, $count
30+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
function Get-YamlMergeNode {
2+
<#
3+
.SYNOPSIS
4+
Resolves aliases to their effective YAML representation node.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([pscustomobject])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[pscustomobject] $Node
11+
)
12+
13+
$effective = $Node
14+
while ($effective.Kind -eq 'Alias') {
15+
$effective = $effective.Target
16+
}
17+
Write-Output -InputObject $effective -NoEnumerate
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
function Get-YamlMergeNodeTag {
2+
<#
3+
.SYNOPSIS
4+
Gets the effective tag used for YAML merge compatibility.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([string])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[pscustomobject] $Node
11+
)
12+
13+
$effective = Get-YamlMergeNode -Node $Node
14+
$value = if ($effective.Kind -eq 'Scalar') {
15+
(Resolve-YamlScalar -Node $effective).Value
16+
} else {
17+
$null
18+
}
19+
Get-YamlEffectiveTag -Node $effective -Value $value
20+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
function Get-YamlMergePath {
2+
<#
3+
.SYNOPSIS
4+
Creates a stable diagnostic path for one YAML mapping entry.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([string])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[string] $Parent,
11+
12+
[Parameter(Mandatory)]
13+
[pscustomobject] $Key,
14+
15+
[Parameter(Mandatory)]
16+
[int] $Index
17+
)
18+
19+
$effective = Get-YamlMergeNode -Node $Key
20+
if ($effective.Kind -eq 'Scalar' -and
21+
(Get-YamlMergeNodeTag -Node $effective) -ceq 'tag:yaml.org,2002:str') {
22+
$value = [string] (Resolve-YamlScalar -Node $effective).Value
23+
if ($value -cmatch '^[A-Za-z_][A-Za-z0-9_-]*$') {
24+
return "$Parent.$value"
25+
}
26+
return "{0}['{1}']" -f $Parent, $value.Replace("'", "''")
27+
}
28+
return "$Parent{key:$Index}"
29+
}

0 commit comments

Comments
 (0)