From a7e6538fda280c20d4697e3ecc2e62d1ac8c15a8 Mon Sep 17 00:00:00 2001 From: Jeffrey <9938813+Raphire@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:11:54 +0200 Subject: [PATCH] feat: enhance FindName reference validation (#826) --- Tests/XamlContracts.Tests.ps1 | 89 ++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/Tests/XamlContracts.Tests.ps1 b/Tests/XamlContracts.Tests.ps1 index c95bddf..cd7aefe 100644 --- a/Tests/XamlContracts.Tests.ps1 +++ b/Tests/XamlContracts.Tests.ps1 @@ -12,25 +12,92 @@ Describe 'XAML UI contracts' { } } - It 'keeps every literal FindName reference backed by an XAML control' { - $xamlNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + It 'keeps every literal FindName reference backed by its receiver schema' { + # Map receivers, not whole files: dialogs also look up controls on their owners. + $receiverSchemas = @{ + 'Show-MainWindow.ps1' = @{ window = 'MainWindow.xaml' } + 'MainWindow-AppSelection.ps1' = @{ window = 'MainWindow.xaml'; w = 'MainWindow.xaml' } + 'MainWindow-Deployment.ps1' = @{ window = 'MainWindow.xaml' } + 'MainWindow-Localization.ps1' = @{ window = 'MainWindow.xaml' } + 'MainWindow-Navigation.ps1' = @{ window = 'MainWindow.xaml' } + 'MainWindow-TweaksBuilder.ps1' = @{ window = 'MainWindow.xaml' } + 'Show-AboutDialog.ps1' = @{ aboutWindow = 'AboutWindow.xaml'; ownerWindow = 'MainWindow.xaml' } + 'Show-AppSelectionWindow.ps1' = @{ window = 'AppSelectionWindow.xaml'; 'script:GuiWindow' = 'MainWindow.xaml' } + 'Show-ApplyModal.ps1' = @{ applyWindow = 'ApplyChangesWindow.xaml'; ownerWindow = 'MainWindow.xaml' } + 'Show-SettingsDialog.ps1' = @{ dialog = 'SettingsWindow.xaml'; Owner = 'MainWindow.xaml' } + 'Show-MessageBox.ps1' = @{ + msgWindow = 'MessageBox.xaml' + ownerWindow = @('MainWindow.xaml', 'AppSelectionWindow.xaml', 'RestoreBackupWindow.xaml') + } + 'Show-ImportExportConfigWindow.ps1' = @{ dlg = 'ImportExportConfigWindow.xaml'; owner = 'MainWindow.xaml' } + 'Show-RestoreBackupDialog.ps1' = @{ window = 'RestoreBackupWindow.xaml'; ownerWindow = 'MainWindow.xaml' } + 'Show-Bubble.ps1' = @{ bubblePanel = 'BubbleHint.xaml' } + } + $schemaNames = @{} foreach ($xamlFile in $script:XamlFiles) { - $content = Get-Content -LiteralPath $xamlFile.FullName -Raw - foreach ($match in [regex]::Matches($content, '(?:x:Name|\bName)\s*=\s*"([^"]+)"')) { - [void]$xamlNames.Add($match.Groups[1].Value) + $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + [xml]$xml = Get-Content -LiteralPath $xamlFile.FullName -Raw + $namespaces = [System.Xml.XmlNamespaceManager]::new($xml.NameTable) + $namespaces.AddNamespace('p', 'http://schemas.microsoft.com/winfx/2006/xaml/presentation') + # Template children belong to separate namescopes and cannot satisfy window lookups. + $attributes = $xml.SelectNodes( + '//@*[not(ancestor::p:ControlTemplate or ancestor::p:DataTemplate or ancestor::p:ItemsPanelTemplate)]', + $namespaces) + foreach ($attribute in $attributes) { + if ($attribute.LocalName -eq 'Name' -and + $attribute.NamespaceURI -in @('', 'http://schemas.microsoft.com/winfx/2006/xaml')) { + [void]$names.Add($attribute.Value) + } } + $schemaNames[$xamlFile.Name] = $names } - $references = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $failures = @() foreach ($guiFile in Get-ChildItem -LiteralPath $script:GuiPath -Filter '*.ps1' -File -Recurse) { - $content = Get-Content -LiteralPath $guiFile.FullName -Raw - foreach ($match in [regex]::Matches($content, "\.FindName\(\s*'([^']+)'\s*\)")) { - [void]$references.Add($match.Groups[1].Value) + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $guiFile.FullName, [ref]$tokens, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + $calls = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Member.Value -eq 'FindName' -and $node.Arguments.Count -eq 1 -and + $node.Arguments[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] + }, $true) + foreach ($call in $calls) { + $name = $call.Arguments[0].Value + $receiver = $call.Expression.Extent.Text + $location = '{0}:{1} {2}.FindName({3})' -f + $guiFile.Name, $call.Extent.StartLineNumber, $receiver, $name + if ($call.Expression -isnot [System.Management.Automation.Language.VariableExpressionAst]) { + $failures += "$location has no receiver mapping" + continue + } + $receiver = $call.Expression.VariablePath.UserPath + $mapping = $receiverSchemas[$guiFile.Name] + $schemas = if ($mapping) { $mapping[$receiver] } else { $null } + if (-not $schemas) { + $failures += "$location has no receiver mapping" + continue + } + foreach ($schema in @($schemas)) { + if (-not $schemaNames.ContainsKey($schema)) { + $failures += "$location maps to missing schema '$schema'" + } + elseif (-not $schemaNames[$schema].Contains($name)) { + # Only the restore dialog owner may omit the message box overlay. + if ($guiFile.Name -eq 'Show-MessageBox.ps1' -and $receiver -eq 'ownerWindow' -and + $schema -eq 'RestoreBackupWindow.xaml' -and $name -ceq 'ModalOverlay') { + continue + } + $failures += "$location is missing from '$schema'" + } + } } } - $missing = @($references | Where-Object { -not $xamlNames.Contains($_) } | Sort-Object) - $missing | Should -BeNullOrEmpty + $failures | Should -BeNullOrEmpty } It 'keeps the main window navigation and deployment controls available' {