feat: enhance FindName reference validation (#826)

This commit is contained in:
Jeffrey
2026-10-14 01:11:54 +02:00
committed by GitHub
parent ab34b3056e
commit a7e6538fda
+78 -11
View File
@@ -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' {