Public/Compare-WsdlDirectory.ps1
|
function Compare-WsdlDirectory { <# .SYNOPSIS Compares the WSDL/XSD service contracts in two directories and reports the specific structural differences (messages, operations, types, and the individual fields within them). .DESCRIPTION Walks both directories, pairs files by their relative path, and for each matched .wsdl / .xsd file parses the XML and extracts the named service components: - Message (with their parts) - Operation (portType input/output/fault message wiring) - Element (top-level schema elements) - ComplexType (with a per-field signature) - SimpleType (restriction base and enumeration values) - Field (every named xsd:element, emitted individually so added/removed/renamed/retyped fields surface on their own line) Each component is compared by name and by a normalized signature, so the output tells you exactly what changed rather than just "the file differs". Files that only exist on one side are reported as File Added / Removed. Non-WSDL files are ignored unless -IncludeAllFiles is supplied, in which case they are compared by content hash. DEFAULT OUTPUT columns: Change, Category, Name, File. Reference and Difference (the before/after signatures) are hidden; use '| Format-List *' or '| Select-Object *' to see them, or '| Format-Table * -Wrap' for a full width view. .PARAMETER ReferenceDirectory The baseline directory (the "before" / left side of the comparison). .PARAMETER DifferenceDirectory The directory to compare against the baseline (the "after" / right side). .PARAMETER Include File name patterns treated as WSDL contracts and compared semantically. Defaults to '*.wsdl' and '*.xsd'. .PARAMETER IncludeAllFiles Also report differences in non-WSDL files (e.g. generated *.cs proxies) using a content-hash comparison. .PARAMETER ShowUnchanged Include components that are identical on both sides in the output. .EXAMPLE Compare-WsdlDirectory ` 'C:\dev\Progistics\trunk\Source\Clients\EndOfDay\WCFServices' ` 'C:\dev\Progistics\releases\Release-6.5\Source\Clients\EndOfDay\WCFServices' .EXAMPLE Compare-WsdlDirectory $trunk $release | Where-Object Change -ne 'Unchanged' | Format-Table * -Wrap .EXAMPLE # Focus on field-level changes only Compare-WsdlDirectory $trunk $release | Where-Object Category -eq 'Field' #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, Position = 0)] [Alias('ReferencePath', 'Source')] [string]$ReferenceDirectory, [Parameter(Mandatory = $true, Position = 1)] [Alias('DifferencePath', 'Destination')] [string]$DifferenceDirectory, [string[]]$Include = @('*.wsdl', '*.xsd'), [switch]$IncludeAllFiles, [switch]$ShowUnchanged ) if ($PSVersionTable.PSVersion.Major -lt 7) { throw "This function requires PowerShell 7.0 or newer." } $refRoot = (Resolve-Path -LiteralPath $ReferenceDirectory -ErrorAction Stop).ProviderPath.TrimEnd('\') $difRoot = (Resolve-Path -LiteralPath $DifferenceDirectory -ErrorAction Stop).ProviderPath.TrimEnd('\') # --- Extract a name->signature map of contract components from one XML file --- $extract = { param([string]$XmlPath) try { $doc = New-Object System.Xml.XmlDocument $doc.Load($XmlPath) } catch { return $null # not parseable as XML; caller falls back to hashing } $records = [System.Collections.Generic.List[pscustomobject]]::new() $emit = { param($cat, $name, $detail) if ([string]::IsNullOrWhiteSpace($name)) { return } $records.Add([pscustomobject]@{ Category = [string]$cat Name = [string]$name Detail = [string]$detail }) } # Messages and their parts. foreach ($m in $doc.SelectNodes("//*[local-name()='message']")) { $parts = foreach ($p in $m.SelectNodes("*[local-name()='part']")) { $pt = $p.GetAttribute('element') if ([string]::IsNullOrEmpty($pt)) { $pt = $p.GetAttribute('type') } '{0}:{1}' -f $p.GetAttribute('name'), $pt } & $emit 'Message' $m.GetAttribute('name') ($parts -join ', ') } # Operations declared on portTypes. foreach ($op in $doc.SelectNodes("//*[local-name()='portType']/*[local-name()='operation']")) { $io = foreach ($c in $op.ChildNodes) { if ($c.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } '{0}={1}' -f $c.LocalName, $c.GetAttribute('message') } & $emit 'Operation' $op.GetAttribute('name') ($io -join ', ') } # Named simple types (enumerations, restrictions). foreach ($st in $doc.SelectNodes("//*[local-name()='simpleType'][@name]")) { $restr = $st.SelectSingleNode("*[local-name()='restriction']") $base = if ($restr) { $restr.GetAttribute('base') } else { '' } $detail = "base=$base" if ($restr) { $enums = foreach ($e in $restr.SelectNodes("*[local-name()='enumeration']")) { $e.GetAttribute('value') } if ($enums) { $detail += "; values={" + ($enums -join '|') + "}" } } & $emit 'SimpleType' $st.GetAttribute('name') $detail } # Type containers: named complexTypes plus top-level schema elements # (which may carry an inline anonymous complexType). $containers = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($ct in $doc.SelectNodes("//*[local-name()='complexType'][@name]")) { $containers.Add([pscustomobject]@{ Node = $ct; Category = 'ComplexType'; Name = $ct.GetAttribute('name') }) } foreach ($el in $doc.SelectNodes("//*[local-name()='schema']/*[local-name()='element'][@name]")) { $containers.Add([pscustomobject]@{ Node = $el; Category = 'Element'; Name = $el.GetAttribute('name') }) } foreach ($container in $containers) { $node = $container.Node $cat = $container.Category $name = $container.Name $typeAttr = $node.GetAttribute('type') $fieldSigs = foreach ($f in $node.SelectNodes(".//*[local-name()='element'][@name]")) { $fName = $f.GetAttribute('name') $fType = $f.GetAttribute('type') if ([string]::IsNullOrEmpty($fType)) { if ($f.SelectSingleNode("*[local-name()='complexType'] | *[local-name()='simpleType']")) { $fType = '(inline)' } } $minO = $f.GetAttribute('minOccurs'); if ([string]::IsNullOrEmpty($minO)) { $minO = '1' } $maxO = $f.GetAttribute('maxOccurs'); if ([string]::IsNullOrEmpty($maxO)) { $maxO = '1' } $nil = if ($f.GetAttribute('nillable') -eq 'true') { ' nillable' } else { '' } & $emit 'Field' ('{0}/{1}' -f $name, $fName) ('{0} [{1}..{2}]{3}' -f $fType, $minO, $maxO, $nil) '{0}:{1} [{2}..{3}]{4}' -f $fName, $fType, $minO, $maxO, $nil } if ($fieldSigs) { & $emit $cat $name ($fieldSigs -join '; ') } elseif ($cat -eq 'Element' -and $typeAttr) { & $emit 'Element' $name "type=$typeAttr" } elseif ($cat -eq 'ComplexType') { & $emit 'ComplexType' $name '(empty)' } } return , $records } $toMap = { param($records) $h = [ordered]@{} foreach ($r in $records) { $h["$($r.Category)|$($r.Name)"] = $r } $h } # --- Pair files by relative path --- $refFiles = @{} foreach ($f in (Get-ChildItem -LiteralPath $refRoot -Recurse -File -ErrorAction SilentlyContinue)) { $refFiles[$f.FullName.Substring($refRoot.Length).TrimStart('\')] = $f } $difFiles = @{} foreach ($f in (Get-ChildItem -LiteralPath $difRoot -Recurse -File -ErrorAction SilentlyContinue)) { $difFiles[$f.FullName.Substring($difRoot.Length).TrimStart('\')] = $f } $allRel = [System.Collections.Generic.SortedSet[string]]::new( [string[]]($refFiles.Keys + $difFiles.Keys), [System.StringComparer]::OrdinalIgnoreCase) $results = [System.Collections.Generic.List[pscustomobject]]::new() $addResult = { param($file, $cat, $name, $change, $ref, $dif) $results.Add([pscustomobject]@{ File = $file Category = $cat Name = $name Change = $change Reference = [string]$ref Difference = [string]$dif }) } foreach ($rel in $allRel) { $inRef = $refFiles.ContainsKey($rel) $inDif = $difFiles.ContainsKey($rel) $isWsdl = $false foreach ($pat in $Include) { if ($rel -like "*$pat" -or (Split-Path $rel -Leaf) -like $pat) { $isWsdl = $true; break } } if (-not $isWsdl -and -not $IncludeAllFiles) { continue } # File present on only one side. if (-not ($inRef -and $inDif)) { $change = if ($inRef) { 'Removed' } else { 'Added' } & $addResult $rel 'File' $rel $change '' '' continue } if ($isWsdl) { $refRecords = & $extract $refFiles[$rel].FullName $difRecords = & $extract $difFiles[$rel].FullName # If either side isn't valid XML, fall back to a hash comparison. if ($null -eq $refRecords -or $null -eq $difRecords) { $refHash = (Get-FileHash -LiteralPath $refFiles[$rel].FullName -Algorithm SHA256).Hash $difHash = (Get-FileHash -LiteralPath $difFiles[$rel].FullName -Algorithm SHA256).Hash if ($refHash -ne $difHash) { & $addResult $rel 'File' $rel 'Changed' 'binary/unparsable' 'binary/unparsable' } elseif ($ShowUnchanged) { & $addResult $rel 'File' $rel 'Unchanged' '' '' } continue } $refMap = & $toMap $refRecords $difMap = & $toMap $difRecords $keys = [System.Collections.Generic.SortedSet[string]]::new( [string[]]($refMap.Keys + $difMap.Keys), [System.StringComparer]::OrdinalIgnoreCase) foreach ($key in $keys) { $r = $refMap[$key] $d = $difMap[$key] $cat = ($key -split '\|', 2)[0] $name = ($key -split '\|', 2)[1] if ($null -ne $r -and $null -eq $d) { & $addResult $rel $cat $name 'Removed' $r.Detail '' } elseif ($null -eq $r -and $null -ne $d) { & $addResult $rel $cat $name 'Added' '' $d.Detail } elseif ($r.Detail -ne $d.Detail) { & $addResult $rel $cat $name 'Changed' $r.Detail $d.Detail } elseif ($ShowUnchanged) { & $addResult $rel $cat $name 'Unchanged' $r.Detail $d.Detail } } } else { # Non-WSDL file, -IncludeAllFiles requested: compare by hash. $refHash = (Get-FileHash -LiteralPath $refFiles[$rel].FullName -Algorithm SHA256).Hash $difHash = (Get-FileHash -LiteralPath $difFiles[$rel].FullName -Algorithm SHA256).Hash if ($refHash -ne $difHash) { & $addResult $rel 'File' $rel 'Changed' '' '' } elseif ($ShowUnchanged) { & $addResult $rel 'File' $rel 'Unchanged' '' '' } } } # --- Emit results with a concise default table view --- $changeRank = @{ Added = 0; Removed = 1; Changed = 2; Unchanged = 3 } $sorted = $results | Sort-Object File, @{ Expression = { $changeRank[$_.Change] } }, Category, Name foreach ($obj in $sorted) { $defaultDisplayProps = @('Change', 'Category', 'Name', 'File') $propSet = [System.Management.Automation.PSPropertySet]::new( 'DefaultDisplayPropertySet', [string[]]$defaultDisplayProps) $memberSet = [System.Management.Automation.PSMemberSet]::new( 'PSStandardMembers', [System.Management.Automation.PSMemberInfo[]]@($propSet)) $obj.PSObject.Members.Add($memberSet) $obj } } |