diff --git a/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll b/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll index ff43456c..e019bb4e 100644 Binary files a/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll and b/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll differ diff --git a/src/tools/winui-analyzer/CHANGELOG.md b/src/tools/winui-analyzer/CHANGELOG.md index 42d6cd0d..fe7ef9f3 100644 --- a/src/tools/winui-analyzer/CHANGELOG.md +++ b/src/tools/winui-analyzer/CHANGELOG.md @@ -20,18 +20,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Now covers `GetForCurrentView`, `Window.Current`, UWP-XAML namespace false friends, and the WebView2 containing-type guard. - **`SuppressionTests.cs`** — pragma-suppression regression test for every shipping rule - (11 tests). A rule that doesn't honor `#pragma warning disable` will turn this red. -- **Corpus regression suite** — [`tools/run-corpus.ps1`](tools/run-corpus.ps1) clones a - curated set of open-source WinUI 3 apps, injects the analyzer, and reports every - diagnostic. Wired to a weekly CI job in `.github/workflows/corpus.yml`. -- **Release pipeline** — `.github/workflows/release.yml` builds, packs, optionally signs - (placeholder), publishes to NuGet on a `v*` tag, and creates a GitHub Release. Manual - dry-run available via workflow_dispatch. + (plus editorconfig-severity suppression coverage for the XAML `WUI2003` diagnostic). + A rule that doesn't honor `#pragma warning disable` will turn this red. +- **`winui-analyze` driver** (`Microsoft.WindowsAppSDK.Analyzers.Driver`) — a standalone, + out-of-build host that runs the analyzers over non-compiling UWP source and emits a + v1.0 JSON migration plan to stdout (`winui-analyze --root --from-uwp`). Findings + carry machine-readable data (`DetectedApi` / `FeatureArea` / migration tier) on + `Diagnostic.Properties`, so the driver never parses localizable message text. ### Changed - `UwpApiAnalyzer.GetForCurrentView` heuristic now consults `Allowlists` instead of inline `Contains("ConnectedAnimationService")` — same behavior, easier to extend, regression-tested. +- **`WUI0003` now also flags `DependencyObject.Dispatcher` member access** (e.g. + `Dispatcher.HasThreadAccess`, `this.Dispatcher.RunAsync(...)`), not just the literal + `CoreDispatcher` type name. The inherited `Dispatcher` property returns `null` in WinUI 3 + desktop apps, so such access compiles clean but throws `NullReferenceException` at launch + (window never appears → run failure) — now surfaced as a **startup-crash** finding. + Detection is symbol-based (a `Dispatcher` property typed `CoreDispatcher`) with a syntactic + fallback (target's rightmost name is exactly `Dispatcher`). The fallback fires **only when the + compilation has no `Windows.UI.Core.CoreDispatcher` metadata** (loose source — the driver's + raw-source path), so real WinUI builds (SDK projections present) resolve symbolically and never + get a false positive. `DispatcherQueue` is unaffected. +- **`WUI2003`** is now categorized `Runtime` (was `Compatibility`) and only fires for controls in + the WinUI/UWP presentation namespace — a custom control named e.g. `Pivot` in a `using:` XAML + namespace is no longer flagged. +- **`winui-analyze` driver** no longer emits a contradictory plan for no-equivalent APIs: a + `WUI1002` finding that also carries a startup-crash tier keeps `severity: startup-crash` but + reports `disposition: defer` with no `fix` (nothing to migrate *to*). ## [0.1.0-alpha] — 2026-04-20 diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Driver/Microsoft.WindowsAppSDK.Analyzers.Driver.csproj b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Driver/Microsoft.WindowsAppSDK.Analyzers.Driver.csproj new file mode 100644 index 00000000..c6efc5a0 --- /dev/null +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Driver/Microsoft.WindowsAppSDK.Analyzers.Driver.csproj @@ -0,0 +1,29 @@ + + + + + Exe + net10.0 + Microsoft.WindowsAppSDK.Analyzers.Driver + winui-analyze + enable + true + + $(NoWarn);CA1515;CA1303;CA1861;CA2007 + + + + + + + + + diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Driver/Program.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Driver/Program.cs new file mode 100644 index 00000000..d09929cb --- /dev/null +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Driver/Program.cs @@ -0,0 +1,366 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using Microsoft.WindowsAppSDK.Analyzers.Rules; + +namespace Microsoft.WindowsAppSDK.Analyzers.Driver; + +/// +/// Self-contained driver that hosts the winui-analyzer over still-UWP source (no restore / +/// build) and emits the migration-plan JSON contract (v1.0) to stdout. Provides the +/// out-of-build entry point the UWP -> WinUI 3 migration tooling consumes at Step 0. +/// +internal static class Program +{ + private const string SchemaVersion = "1.0"; + private const string MigrationTierKey = "MigrationTier"; + private const string DetectedApiKey = "DetectedApi"; + private const string FeatureAreaKey = "FeatureArea"; + private const string StartupCrashTier = "startup-crash"; + private const string SensitiveTier = "sensitive"; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static async Task Main(string[] args) + { + string? root = null; + string? projectFile = null; + bool fromUwp = false; + for (int i = 0; i < args.Length; i++) + { + var a = args[i]; + if (a is "--root" && i + 1 < args.Length) { root = args[++i]; } + else if (a is "--project" && i + 1 < args.Length) { projectFile = args[++i]; } + else if (a is "--from-uwp") { fromUwp = true; } + else if (!a.StartsWith("--", StringComparison.Ordinal) && root is null) { root = a; } + } + + if (root is null) + { + await Console.Error.WriteLineAsync( + "usage: winui-analyze [--root] [--project ] [--from-uwp]"); + return 2; + } + + root = Path.GetFullPath(root); + if (!Directory.Exists(root)) + { + await Console.Error.WriteLineAsync($"error: directory not found: {root}"); + return 2; + } + + try + { + var report = await AnalyzeAsync(root, projectFile, fromUwp); + Console.Out.WriteLine(JsonSerializer.Serialize(report, JsonOpts)); + return 0; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"error: analyze failed: {ex.Message}"); + return 1; + } + } + + private static async Task AnalyzeAsync(string root, string? projectFile, bool fromUwp) + { + var csFiles = EnumerateSource(root, "*.cs"); + var trees = csFiles + .Select(p => CSharpSyntaxTree.ParseText( + SourceText.From(File.ReadAllText(p), Encoding.UTF8), path: p)) + .ToImmutableArray(); + + var additionalTexts = EnumerateSource(root, "*.xaml") + .Concat(EnumerateSource(root, "*.appxmanifest")) + .Select(p => (AdditionalText)new PhysicalAdditionalText(p)) + .ToImmutableArray(); + + var compilation = CSharpCompilation.Create( + assemblyName: "MigrationAnalysisTarget", + syntaxTrees: trees, + references: TrustedReferences(), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzerOptions = fromUwp + ? new AnalyzerOptions(additionalTexts, new ForceMigrationOptionsProvider()) + : new AnalyzerOptions(additionalTexts); + var withAnalyzers = compilation.WithAnalyzers(Analyzers, analyzerOptions); + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); + + // Group by originating file (relative to root); keep a stable order. + var byFile = new SortedDictionary>(StringComparer.Ordinal); + var featureAreaByFile = new Dictionary(StringComparer.Ordinal); + + foreach (var d in diagnostics.Where(IsOurs)) + { + var span = d.Location.GetLineSpan(); + var absPath = span.Path; + if (string.IsNullOrEmpty(absPath)) continue; + var rel = Relativize(root, absPath); + + var severity = SeverityOf(d); + if (!byFile.TryGetValue(rel, out var list)) + { + list = new List(); + byFile[rel] = list; + } + + list.Add(new Finding( + Id: d.Id, + Severity: severity, + Detected: DetectedFrom(d), + Location: new FindingLocation(rel, span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1), + Fix: FixOf(d, severity))); + + if (d.Id == "WUI1010" && !featureAreaByFile.ContainsKey(rel)) + { + var area = FeatureAreaFrom(d); + if (area is not null) featureAreaByFile[rel] = area; + } + } + + var files = byFile.Select(kvp => + { + featureAreaByFile.TryGetValue(kvp.Key, out var area); + return new FileEntry( + Path: kvp.Key, + Disposition: DispositionOf(kvp.Value), + FeatureArea: area, + Findings: kvp.Value + .OrderBy(f => f.Location.Line) + .ThenBy(f => f.Location.Column) + .ToList()); + }).ToList(); + + var totalFindings = files.Sum(f => f.Findings.Count); + var crashFindings = files.Sum(f => f.Findings.Count(x => x.Severity == StartupCrashTier)); + + return new Report( + SchemaVersion, + new Source(root.Replace('\\', '/'), projectFile), + new Summary(files.Count, totalFindings, crashFindings), + files); + } + + // ── Analyzer set ──────────────────────────────────────────────────────── + private static ImmutableArray Analyzers => + ImmutableArray.Create( + new UwpApiAnalyzer(), + new ApiMappingAnalyzer(), + new XamlAnalyzer(), + new XamlCodeBehindAnalyzer(), + new TabViewContentAnalyzer(), + new AttachedPropertyAnalyzer(), + new MvvmPatternAnalyzer(), + new WebView2InitAnalyzer(), + new GenAiApiAnalyzer()); + + private static readonly ImmutableHashSet OurIds = + Analyzers.SelectMany(a => a.SupportedDiagnostics).Select(d => d.Id).ToImmutableHashSet(); + + private static bool IsOurs(Diagnostic d) => OurIds.Contains(d.Id); + + // ── Mapping: analyzer diagnostic → contract fields ────────────────────── + private static string SeverityOf(Diagnostic d) + { + // Explicit migration-tier signal (startup-crash / sensitive) always wins. + if (d.Properties.TryGetValue(MigrationTierKey, out var tier) + && (tier == StartupCrashTier || tier == SensitiveTier)) + { + return tier!; + } + return d.Id switch + { + "WUI1002" => "unsupported", + "WUI1001" => "adaptable", + // WUI1010 feature hints are informational unless flagged sensitive above. + "WUI1010" => "adaptable", + _ => "adaptable", + }; + } + + private static string DispositionOf(IReadOnlyCollection findings) + { + // A no-equivalent API (WUI1002) cannot be migrated in place. Even when it ALSO carries a + // startup-crash tier — in which case its severity reads "startup-crash", not "unsupported" + // — the file must still defer. Key off the finding id, not just the mapped severity, so the + // startup-crash + no-equiv case doesn't fall through to "migrate". + if (findings.Any(f => f.Severity == "unsupported" || f.Id == "WUI1002")) return "defer"; + if (findings.Any(f => f.Severity == "sensitive")) return "sequential-manual"; + return "migrate"; + } + + private static string DetectedFrom(Diagnostic d) + { + // Prefer the machine-readable value the mapping analyzer stamps on the property bag + // (DetectedApi) — robust to message localization. Fall back to the rule Title for the + // syntactic rules (WUI0xxx / WUI2xxx) that don't carry it. + if (d.Properties.TryGetValue(DetectedApiKey, out var detected) && !string.IsNullOrEmpty(detected)) + { + return detected!; + } + return d.Descriptor.Title.ToString(CultureInfo.InvariantCulture); + } + + private static string? FeatureAreaFrom(Diagnostic d) + { + return d.Properties.TryGetValue(FeatureAreaKey, out var area) && !string.IsNullOrEmpty(area) + ? area + : null; + } + + private static Fix? FixOf(Diagnostic d, string severity) + { + // No fix to point at when the API has no WinAppSDK equivalent — whether surfaced through the + // "unsupported" severity or as a no-equivalent (WUI1002) finding that a crash tier + // reclassified to "startup-crash". Emitting a "migrate to X" fix there would contradict the + // finding. + if (severity == "unsupported" || d.Id == "WUI1002") return null; + var refUri = string.IsNullOrEmpty(d.Descriptor.HelpLinkUri) ? null : d.Descriptor.HelpLinkUri; + return new Fix(refUri, d.GetMessage(CultureInfo.InvariantCulture)); + } + + // ── File / reference helpers ──────────────────────────────────────────── + private static IEnumerable EnumerateSource(string root, string pattern) => + Directory.EnumerateFiles(root, pattern, SearchOption.AllDirectories) + .Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) + && !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p, StringComparer.Ordinal); + + private static string Relativize(string root, string path) + { + var rel = Path.GetRelativePath(root, path); + return rel.Replace('\\', '/'); + } + + private static ImmutableArray TrustedReferences() + { + var trusted = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty; + var refs = trusted + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .ToList(); + + // Best-effort: add the UWP union metadata so semantic (member/type) rules — including the + // DisplayRequest startup-crash tier — can resolve Windows.* symbols. Absence degrades + // gracefully to syntactic-only findings. + var winmd = LocateWindowsWinmd(); + if (winmd != null) + { + refs.Add(MetadataReference.CreateFromFile(winmd)); + } + + return refs.ToImmutableArray(); + } + + private static string? LocateWindowsWinmd() + { + foreach (var pf in new[] + { + Environment.GetEnvironmentVariable("ProgramFiles(x86)"), + Environment.GetEnvironmentVariable("ProgramFiles"), + }) + { + if (string.IsNullOrEmpty(pf)) continue; + var unionRoot = Path.Combine(pf, "Windows Kits", "10", "UnionMetadata"); + if (!Directory.Exists(unionRoot)) continue; + + var newest = Directory.EnumerateDirectories(unionRoot) + .Select(d => (dir: d, name: Path.GetFileName(d))) + .Where(x => Version.TryParse(x.name, out _)) + .OrderByDescending(x => Version.Parse(x.name)) + .Select(x => Path.Combine(x.dir, "Windows.winmd")) + .FirstOrDefault(File.Exists); + if (newest != null) return newest; + } + return null; + } + + private sealed class PhysicalAdditionalText : AdditionalText + { + private readonly SourceText _text; + public PhysicalAdditionalText(string path) + { + Path = path; + _text = SourceText.From(File.ReadAllText(path), Encoding.UTF8); + } + public override string Path { get; } + public override SourceText? GetText(CancellationToken cancellationToken = default) => _text; + } + + // B4: forces migration-only rules to fire regardless of source markers. Set when the caller + // passes --from-uwp; read by ProjectContext.Detect via the global analyzer-config options. + private sealed class ForceMigrationOptionsProvider : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new ForcedOptions(); + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions; + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions; + + private sealed class ForcedOptions : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string value) + { + if (string.Equals(key, "build_property.WinUIMigrationFromUwp", StringComparison.Ordinal)) + { + value = "true"; + return true; + } + value = null!; + return false; + } + } + } +} + +// ── Contract DTOs (migration-plan v1.0) ───────────────────────────────────── +internal sealed record Report( + [property: JsonPropertyName("schemaVersion")] string SchemaVersion, + [property: JsonPropertyName("source")] Source Source, + [property: JsonPropertyName("summary")] Summary Summary, + [property: JsonPropertyName("files")] IReadOnlyList Files); + +internal sealed record Source( + [property: JsonPropertyName("root")] string Root, + [property: JsonPropertyName("projectFile")] string? ProjectFile); + +internal sealed record Summary( + [property: JsonPropertyName("filesAnalyzed")] int FilesAnalyzed, + [property: JsonPropertyName("findings")] int Findings, + [property: JsonPropertyName("startupCrashFindings")] int StartupCrashFindings); + +internal sealed record FileEntry( + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("disposition")] string Disposition, + [property: JsonPropertyName("featureArea")] string? FeatureArea, + [property: JsonPropertyName("findings")] IReadOnlyList Findings); + +internal sealed record Finding( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("detected")] string Detected, + [property: JsonPropertyName("location")] FindingLocation Location, + [property: JsonPropertyName("fix")] Fix? Fix); + +internal sealed record FindingLocation( + [property: JsonPropertyName("file")] string File, + [property: JsonPropertyName("line")] int Line, + [property: JsonPropertyName("column")] int Column); + +internal sealed record Fix( + [property: JsonPropertyName("ref")] string? Ref, + [property: JsonPropertyName("summary")] string Summary); diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/AnalyzerTest.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/AnalyzerTest.cs index ceb6b74e..9207c4ad 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/AnalyzerTest.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/AnalyzerTest.cs @@ -33,7 +33,21 @@ namespace Microsoft.WindowsAppSDK.Analyzers.Tests; private readonly List<(string path, string content)> _sources = new(); private readonly List<(string path, string content)> _additionalFiles = new(); private readonly List<(string id, DiagnosticSeverity? severity)> _expected = new(); + private readonly List<(string id, string key, string value)> _expectedProps = new(); + private readonly List<(string id, string key)> _expectedAbsentProps = new(); private bool _expectClean; + private bool _forceMigration; + private readonly List _suppressedIds = new(); + + /// + /// Sets the global analyzer-config option that the analyze/validate driver uses (via --from-uwp) + /// to force migration-only rules to fire regardless of source markers (B4). + /// + public AnalyzerTest ForceMigration() + { + _forceMigration = true; + return this; + } public AnalyzerTest WithSource(string source, string path = "Test0.cs") { @@ -47,6 +61,18 @@ public AnalyzerTest WithXaml(string path, string content) return this; } + /// + /// Suppresses a diagnostic ID via compilation diagnostic options — the editorconfig + /// (dotnet_diagnostic.WUIxxxx.severity = none) suppression vector. Used to assert + /// suppressibility of XAML AdditionalFile diagnostics that a C#-source #pragma + /// cannot reach. + /// + public AnalyzerTest SuppressViaConfig(string id) + { + _suppressedIds.Add(id); + return this; + } + public AnalyzerTest ExpectDiagnostic(string id, DiagnosticSeverity? severity = null) { _expected.Add((id, severity)); @@ -60,6 +86,20 @@ public AnalyzerTest ExpectClean() return this; } + /// Assert that the diagnostic with carries a property. + public AnalyzerTest ExpectProperty(string id, string key, string value) + { + _expectedProps.Add((id, key, value)); + return this; + } + + /// Assert that the diagnostic with does NOT carry a property key. + public AnalyzerTest ExpectPropertyAbsent(string id, string key) + { + _expectedAbsentProps.Add((id, key)); + return this; + } + public async Task RunAsync() { if (_sources.Count == 0) @@ -74,20 +114,30 @@ public async Task RunAsync() var references = GetMetadataReferences(); + var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + if (_suppressedIds.Count > 0) + { + compilationOptions = compilationOptions.WithSpecificDiagnosticOptions( + _suppressedIds.ToImmutableDictionary(id => id, _ => ReportDiagnostic.Suppress)); + } + var compilation = CSharpCompilation.Create( assemblyName: "Microsoft.WindowsAppSDK.Analyzers.Tests.Sample", syntaxTrees: trees, references: references, - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + options: compilationOptions); var additionalTexts = _additionalFiles .Select(f => (AdditionalText)new InMemoryAdditionalText(f.path, f.content)) .ToImmutableArray(); var analyzer = new TAnalyzer(); + var analyzerOptions = _forceMigration + ? new AnalyzerOptions(additionalTexts, new ForceMigrationOptionsProvider()) + : new AnalyzerOptions(additionalTexts); var withAnalyzers = compilation.WithAnalyzers( ImmutableArray.Create(analyzer), - new AnalyzerOptions(additionalTexts)); + analyzerOptions); var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); @@ -119,6 +169,26 @@ public async Task RunAsync() Assert.NotNull(match); Assert.Equal(exp.severity!.Value, match.Severity); } + + foreach (var ep in _expectedProps) + { + var match = actual.FirstOrDefault(d => d.Id == ep.id); + Assert.NotNull(match); + Assert.True( + match!.Properties.TryGetValue(ep.key, out var v) && v == ep.value, + $"Expected diagnostic {ep.id} to carry property {ep.key}={ep.value}, " + + $"but got [{string.Join(", ", match.Properties.Select(p => $"{p.Key}={p.Value}"))}]"); + } + + foreach (var ep in _expectedAbsentProps) + { + var match = actual.FirstOrDefault(d => d.Id == ep.id); + Assert.NotNull(match); + Assert.False( + match!.Properties.ContainsKey(ep.key), + $"Expected diagnostic {ep.id} to NOT carry property {ep.key}, " + + $"but got [{string.Join(", ", match.Properties.Select(p => $"{p.Key}={p.Value}"))}]"); + } } private static ImmutableArray GetMetadataReferences() @@ -143,4 +213,25 @@ public InMemoryAdditionalText(string path, string content) public override string Path { get; } public override SourceText? GetText(CancellationToken cancellationToken = default) => _text; } + + private sealed class ForceMigrationOptionsProvider : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new ForcedOptions(); + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions; + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions; + + private sealed class ForcedOptions : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string value) + { + if (string.Equals(key, "build_property.WinUIMigrationFromUwp", StringComparison.Ordinal)) + { + value = "true"; + return true; + } + value = null!; + return false; + } + } + } } diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/ApiMappingAnalyzerTests.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/ApiMappingAnalyzerTests.cs index 335c5d0d..42a49c1b 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/ApiMappingAnalyzerTests.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/ApiMappingAnalyzerTests.cs @@ -71,4 +71,107 @@ public async Task Wui1010FeatureHintFiresOnFeatureNamespace() .ExpectDiagnostic(DiagnosticIds.FeatureMappingHint) .RunAsync(); } + + [Fact] + public async Task Wui1001FlagsToastNotificationManager() + { + // B1: ToastNotificationManager has a WinAppSDK equivalent → WUI1001. + await new AnalyzerTest() + .WithSource(@" +namespace Windows.UI.Notifications { public class ToastNotificationManager { public static object CreateToastNotifier() => new(); } } +namespace Sample { class C { void M() { var n = global::Windows.UI.Notifications.ToastNotificationManager.CreateToastNotifier(); } } }") + .WithXaml("Package.appxmanifest", UwpManifest) + .ExpectDiagnostic(DiagnosticIds.ApiMappingMatch) + .RunAsync(); + } + + [Fact] + public async Task Wui1002FlagsRadialControllerNoEquivalent() + { + // B1: RadialController has no WinAppSDK equivalent → WUI1002. + await new AnalyzerTest() + .WithSource(@" +namespace Windows.UI.Input { public class RadialController { public static RadialController CreateForCurrentView() => new(); } } +namespace Sample { class C { void M() { var r = global::Windows.UI.Input.RadialController.CreateForCurrentView(); } } }") + .WithXaml("Package.appxmanifest", UwpManifest) + .ExpectDiagnostic(DiagnosticIds.ApiMappingNoEquiv) + .RunAsync(); + } + + [Fact] + public async Task Wui1001FlagsApplicationDataLocalSettings() + { + // B1: ApplicationData.LocalSettings maps to Microsoft.Windows.Storage.ApplicationData → WUI1001. + await new AnalyzerTest() + .WithSource(@" +namespace Windows.Storage { public class ApplicationData { public static ApplicationData Current => new(); public object LocalSettings => new(); } } +namespace Sample { class C { void M() { var s = global::Windows.Storage.ApplicationData.Current.LocalSettings; } } }") + .WithXaml("Package.appxmanifest", UwpManifest) + .ExpectDiagnostic(DiagnosticIds.ApiMappingMatch) + .RunAsync(); + } + + [Fact] + public async Task Wui1002DisplayRequestCarriesStartupCrashTier() + { + // B2: DisplayRequest is a runtime crasher → WUI1002 + startup-crash tier property. + await new AnalyzerTest() + .WithSource(@" +namespace Windows.System.Display { public class DisplayRequest { public void RequestActive() {} } } +namespace Sample { class C { void M() { var d = new global::Windows.System.Display.DisplayRequest(); d.RequestActive(); } } }") + .WithXaml("Package.appxmanifest", UwpManifest) + .ExpectDiagnostic(DiagnosticIds.ApiMappingNoEquiv) + .ExpectProperty(DiagnosticIds.ApiMappingNoEquiv, MigrationTiers.PropertyKey, MigrationTiers.StartupCrash) + .RunAsync(); + } + + [Fact] + public async Task Wui1010FeatureHintFiresOnSensorsNamespace() + { + // B1: sensitive sensor family is a feature area → WUI1010 carrying the sensitive tier. + await new AnalyzerTest() + .WithSource("using Windows.Devices.Sensors; class C {}") + .WithXaml("Package.appxmanifest", UwpManifest) + .ExpectDiagnostic(DiagnosticIds.FeatureMappingHint) + .ExpectProperty(DiagnosticIds.FeatureMappingHint, MigrationTiers.PropertyKey, MigrationTiers.Sensitive) + .RunAsync(); + } + + [Fact] + public async Task Wui1010NonSensitiveFeatureHintCarriesNoSensitiveTier() + { + // Gap #2: a plain Windows.UI.Xaml namespace hint must NOT be flagged sensitive, + // so it does not wrongly force sequential-manual pacing downstream. + await new AnalyzerTest() + .WithSource("using Windows.UI.Xaml.Controls; class C {}") + .WithXaml("Package.appxmanifest", UwpManifest) + .ExpectDiagnostic(DiagnosticIds.FeatureMappingHint) + .ExpectPropertyAbsent(DiagnosticIds.FeatureMappingHint, MigrationTiers.PropertyKey) + .RunAsync(); + } + + [Fact] + public async Task GatedRulesStaySilentWithoutUwpMarkersOrForceFlag() + { + // No Package.appxmanifest and no Windows.UI.Xaml marker → context is not MigratingFromUwp, + // so the gated ApiMappingAnalyzer stays silent (false-positive guard on greenfield/unknown). + await new AnalyzerTest() + .WithSource("using Windows.Devices.Sensors; class C {}") + .ExpectClean() + .RunAsync(); + } + + [Fact] + public async Task B4ForceMigrationFiresGatedRulesWithoutUwpMarkers() + { + // B4: the analyze/validate driver's --from-uwp sets the global option; gated rules then fire + // even on mostly-migrated target source (no manifest, no Windows.UI.Xaml) so validate can + // catch API residue. + await new AnalyzerTest() + .WithSource("using Windows.Devices.Sensors; class C {}") + .ForceMigration() + .ExpectDiagnostic(DiagnosticIds.FeatureMappingHint) + .ExpectProperty(DiagnosticIds.FeatureMappingHint, MigrationTiers.PropertyKey, MigrationTiers.Sensitive) + .RunAsync(); + } } diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/SuppressionTests.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/SuppressionTests.cs index 58ae7ed6..bbaf8493 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/SuppressionTests.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/SuppressionTests.cs @@ -47,6 +47,26 @@ class App { void M() { .RunAsync(); } + // ─── WUI0003 — DependencyObject.Dispatcher member access ───────────────── + [Fact] + public async Task SuppressWui0003() + { + // WUI0003 is a C# member-access diagnostic, so #pragma reaches it. Loose source (no + // CoreDispatcher metadata) exercises the syntactic fallback path. + await new AnalyzerTest() + .WithSource(@" +namespace Sample { + class MyPage { + void M() { +#pragma warning disable WUI0003 + if (Dispatcher.HasThreadAccess) { } +#pragma warning restore WUI0003 + } + } +}") + .RunAsync(); + } + // ─── WUI0004 — GetForCurrentView ───────────────────────────────────────── [Fact] public async Task SuppressWui0004() @@ -79,6 +99,23 @@ class C { void M() { .RunAsync(); } + // ─── WUI2003 — UWP-only XAML control (XAML AdditionalFile diagnostic) ───── + [Fact] + public async Task SuppressWui2003ViaConfig() + { + // WUI2003 is reported on a XAML AdditionalFile, so a C#-source `#pragma warning disable` + // cannot reach it. The supported suppression vector is editorconfig severity + // (`dotnet_diagnostic.WUI2003.severity = none`), modeled here via SpecificDiagnosticOptions. + var xaml = @" + +"; + await new AnalyzerTest() + .WithXaml("MainPage.xaml", xaml) + .SuppressViaConfig(DiagnosticIds.UwpOnlyXamlControl) + .ExpectClean() + .RunAsync(); + } + // ─── WUI3001 — Old MVVM syntax ─────────────────────────────────────────── [Fact] public async Task SuppressWui3001() diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/UwpApiAnalyzerTests.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/UwpApiAnalyzerTests.cs index ed543856..2a80ade1 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/UwpApiAnalyzerTests.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/UwpApiAnalyzerTests.cs @@ -57,6 +57,82 @@ class App { void M() { var w = Window.Current; } }") .RunAsync(); } + // ─── WUI0003 — DependencyObject.Dispatcher (null in WinUI 3 → launch NRE) ─ + [Fact] + public async Task Wui0003FlagsDependencyObjectDispatcherMemberAccess() + { + // Regression: the ApplicationData sample left `Dispatcher.HasThreadAccess` unmigrated. + // DependencyObject.Dispatcher is null in WinUI 3 → NullReferenceException at launch. + await new AnalyzerTest() + .WithSource(@" +namespace Windows.UI.Core { public class CoreDispatcher { public bool HasThreadAccess => true; } } +namespace Sample { + class DependencyObject { +#pragma warning disable WUI0003 + public Windows.UI.Core.CoreDispatcher Dispatcher => null!; +#pragma warning restore WUI0003 + } + class MyPage : DependencyObject { + void M() { if (Dispatcher.HasThreadAccess) { } } + } +}") + .ExpectDiagnostic(DiagnosticIds.CoreDispatcher) + .ExpectProperty(DiagnosticIds.CoreDispatcher, MigrationTiers.PropertyKey, MigrationTiers.StartupCrash) + .RunAsync(); + } + + [Fact] + public async Task Wui0003FlagsUnresolvedDispatcherAccessInLooseSource() + { + // Driver path: analysis runs over raw source with no WinUI metadata, so `Dispatcher` + // does not bind to a symbol. The syntactic fallback must still flag it (this is the exact + // run32 ApplicationData regression that shipped a launch crash). + await new AnalyzerTest() + .WithSource(@" +namespace Sample { + class MyPage { + void M() { if (Dispatcher.HasThreadAccess) { } } + } +}") + .ExpectDiagnostic(DiagnosticIds.CoreDispatcher) + .ExpectProperty(DiagnosticIds.CoreDispatcher, MigrationTiers.PropertyKey, MigrationTiers.StartupCrash) + .RunAsync(); + } + + [Fact] + public async Task Wui0003DoesNotFlagDispatcherQueue() + { + // False-positive guard: DispatcherQueue is the correct WinUI 3 API and must not be flagged. + await new AnalyzerTest() + .WithSource(@" +namespace Sample { + class DispatcherQueue { public bool HasThreadAccess => true; } + class MyPage { + DispatcherQueue DispatcherQueue => new(); + void M() { if (DispatcherQueue.HasThreadAccess) { } } + } +}") + .RunAsync(); + } + + [Fact] + public async Task Wui0003DoesNotFlagUnresolvedDispatcherWhenMetadataPresent() + { + // FP guard (M8): in a real referenced build (CoreDispatcher metadata present), an + // unbindable `.Dispatcher` access (e.g. target of an unresolved type mid-edit) must NOT + // fire — the syntactic fallback is gated to loose-source only, so the precise semantic + // path is authoritative. Without the gate this would be a false positive. + await new AnalyzerTest() + .WithSource(@" +namespace Windows.UI.Core { public class CoreDispatcher { public bool HasThreadAccess => true; } } +namespace Sample { + class MyPage { + void M(UnresolvedType x) { if (x.Dispatcher.HasThreadAccess) { } } + } +}") + .RunAsync(); + } + // ─── WUI0004 — GetForCurrentView ───────────────────────────────────────── [Fact] public async Task Wui0004FlagsGetForCurrentView() @@ -69,6 +145,19 @@ class App { void M() { var s = StatusBar.GetForCurrentView(); } }") .RunAsync(); } + [Fact] + public async Task Wui0004GetForCurrentViewCarriesStartupCrashTier() + { + // B2: view-scoped GetForCurrentView is a runtime crasher → startup-crash tier property. + await new AnalyzerTest() + .WithSource(@" +class StatusBar { public static StatusBar GetForCurrentView() => new(); } +class App { void M() { var s = StatusBar.GetForCurrentView(); } }") + .ExpectDiagnostic(DiagnosticIds.GetForCurrentView) + .ExpectProperty(DiagnosticIds.GetForCurrentView, MigrationTiers.PropertyKey, MigrationTiers.StartupCrash) + .RunAsync(); + } + [Fact] public async Task Wui0004DoesNotFlagConnectedAnimationServiceAllowlist() { diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/XamlAnalyzerTests.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/XamlAnalyzerTests.cs index 68a37c85..f0b08561 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/XamlAnalyzerTests.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.Tests/Rules/XamlAnalyzerTests.cs @@ -103,4 +103,78 @@ public async Task Wui2020DoesNotFlagAppXaml() .WithXaml("App.xaml", xaml) .RunAsync(); } + + [Fact] + public async Task Wui2003FlagsPivot() + { + var xaml = @" + +"; + await new AnalyzerTest() + .WithSource(MinimalCs) + .WithXaml("MainPage.xaml", xaml) + .ExpectDiagnostic(DiagnosticIds.UwpOnlyXamlControl) + .RunAsync(); + } + + [Fact] + public async Task Wui2003FlagsVirtualizingStackPanel() + { + var xaml = @" + +"; + await new AnalyzerTest() + .WithSource(MinimalCs) + .WithXaml("MainPage.xaml", xaml) + .ExpectDiagnostic(DiagnosticIds.UwpOnlyXamlControl) + .RunAsync(); + } + + [Fact] + public async Task Wui2003FlagsHubAndSection() + { + var xaml = @" + + + +"; + await new AnalyzerTest() + .WithSource(MinimalCs) + .WithXaml("MainPage.xaml", xaml) + .ExpectDiagnostic(DiagnosticIds.UwpOnlyXamlControl) + .ExpectDiagnostic(DiagnosticIds.UwpOnlyXamlControl) + .RunAsync(); + } + + [Fact] + public async Task Wui2003DoesNotFlagWinUiControls() + { + // FP guard: WinUI 3 controls that survive migration must stay clean. + var xaml = @" + + + + +"; + await new AnalyzerTest() + .WithSource(MinimalCs) + .WithXaml("MainPage.xaml", xaml) + .RunAsync(); + } + + [Fact] + public async Task Wui2003DoesNotFlagCustomControlInUserNamespace() + { + // FP guard (M9): a custom control named `Pivot` in a non-presentation `using:` namespace + // is not the UWP Pivot and must not fire — WUI2003 matches only in the WinUI/UWP + // presentation namespace. + var xaml = @" + +"; + await new AnalyzerTest() + .WithSource(MinimalCs) + .WithXaml("MainPage.xaml", xaml) + .RunAsync(); + } } diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.slnx b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.slnx index 74a37d4d..2833d2fb 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.slnx +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers.slnx @@ -1,6 +1,7 @@ + diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ApiMappings.g.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ApiMappings.g.cs index a73edeca..f6e5ac77 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ApiMappings.g.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ApiMappings.g.cs @@ -88,9 +88,28 @@ internal static class ApiMappings // ─── No-equiv (currently unsupported) ───────────────────────────────── new ApiMapping("Windows.Graphics.Printing.PrintManager", null, null), - new ApiMapping("Windows.System.Display.DisplayRequest", null, null), + new ApiMapping("Windows.System.Display.DisplayRequest", null, null, startupCrash: true), new ApiMapping("Windows.UI.Text.Core.CoreTextServicesManager", null, "Windows 11 only"), - new ApiMapping("Windows.UI.Core.SystemNavigationManager.GetForCurrentView", null, null) + new ApiMapping("Windows.UI.Core.SystemNavigationManager.GetForCurrentView", null, null), + + // ─── B1: additional migration-table coverage (UWP → WinAppSDK) ─────────── + // Adaptable (WinAppSDK equivalent exists → WUI1001): + new ApiMapping("Windows.UI.Xaml.Controls.MediaElement", "Microsoft.UI.Xaml.Controls.MediaPlayerElement", "guides/winui"), + new ApiMapping("Windows.UI.Xaml.Controls.CaptureElement", "Microsoft.UI.Xaml.Controls.MediaPlayerElement", "guides/winui"), + new ApiMapping("Windows.UI.Notifications.ToastNotificationManager", "Microsoft.Windows.AppNotifications.AppNotificationManager", "guides/notifications"), + new ApiMapping("Windows.Networking.PushNotifications.PushNotificationChannelManager", "Microsoft.Windows.PushNotifications.PushNotificationManager", "guides/notifications"), + new ApiMapping("Windows.Storage.ApplicationData.LocalSettings", "Microsoft.Windows.Storage.ApplicationData.GetDefault().LocalSettings", "guides/applicationdata"), + new ApiMapping("Windows.Storage.ApplicationData.LocalFolder", "Microsoft.Windows.Storage.ApplicationData.GetDefault().LocalFolder", "guides/applicationdata"), + + // Unsupported (no WinAppSDK desktop equivalent → WUI1002): + new ApiMapping("Windows.UI.Xaml.Controls.InkCanvas", null, "no WinUI 3 desktop equivalent"), + new ApiMapping("Windows.UI.Input.RadialController", null, "UWP-only Surface Dial input"), + new ApiMapping("Windows.UI.Text.Core.CoreTextEditContext", null, "UWP-only custom IME / text-input integration"), + new ApiMapping("Windows.ApplicationModel.Contacts.ContactManager", null, "UWP-only system contact UI"), + new ApiMapping("Windows.ApplicationModel.Contacts.ContactPicker", null, "UWP-only system contact UI"), + new ApiMapping("Windows.System.Profile.AnalyticsInfo", null, "device-family branching has no desktop analog"), + new ApiMapping("Windows.Storage.ApplicationData.RoamingSettings", null, "roaming app data removed in WinAppSDK"), + new ApiMapping("Windows.Storage.ApplicationData.RoamingFolder", null, "roaming app data removed in WinAppSDK") ); /// Lookup by exact symbol display string (namespace-qualified). @@ -114,14 +133,17 @@ private static ImmutableDictionary BuildLookup() /// internal sealed class ApiMapping { - public ApiMapping(string uwpQualifiedName, string? winAppSdkReplacement, string? learnAnchor) + public ApiMapping(string uwpQualifiedName, string? winAppSdkReplacement, string? learnAnchor, bool startupCrash = false) { UwpQualifiedName = uwpQualifiedName; WinAppSdkReplacement = winAppSdkReplacement; LearnAnchor = learnAnchor; + StartupCrash = startupCrash; } public string UwpQualifiedName { get; } /// Replacement guidance text. null if no equivalent yet. public string? WinAppSdkReplacement { get; } public string? LearnAnchor { get; } + /// True if leaving this API unaddressed throws at runtime (blank-window crash). + public bool StartupCrash { get; } } diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/DiagnosticIds.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/DiagnosticIds.cs index 6fe9455b..4732cdc4 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/DiagnosticIds.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/DiagnosticIds.cs @@ -26,6 +26,7 @@ internal static class DiagnosticIds // 200x = layout/control-content public const string TabViewRawContent = "WUI2001"; // ex-WUI001 public const string TabViewRawContentXaml = "WUI2002"; // ex-WUI021 (cross-file variant) + public const string UwpOnlyXamlControl = "WUI2003"; // UWP-only XAML control with no WinUI 3 equivalent // 201x = XAML binding (x:Bind) public const string XBindNestedNoFallback = "WUI2010"; // ex-WUI007 public const string XBindMissingMode = "WUI2011"; // ex-WUI011 diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/FeatureMappings.g.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/FeatureMappings.g.cs index afc2383a..7a29f306 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/FeatureMappings.g.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/FeatureMappings.g.cs @@ -30,7 +30,35 @@ internal static class FeatureMappings new FeatureMapping("Windows.Web.UI", "WebView", "WebView (UWP) → WebView2 (Microsoft.Web.WebView2). EnsureCoreWebView2Async required."), new FeatureMapping("Windows.Security.Authentication.Web", "OAuth", - "WebAuthenticationBroker → OAuth2Manager (WinAppSDK 1.7+). See develop/security/oauth2.") + "WebAuthenticationBroker → OAuth2Manager (WinAppSDK 1.7+). See develop/security/oauth2."), + + // ─── B1: sensitive feature families (drive SEQUENTIAL pacing via featureArea) ─── + new FeatureMapping("Windows.Media.Capture", "Media capture", + "Camera/media capture family. Preview via MediaPlayerElement; see guides/winui.", sensitive: true), + new FeatureMapping("Windows.Media.SpeechRecognition", "Speech", + "Speech recognition family — review WinAppSDK/Windows.Media support before migrating.", sensitive: true), + new FeatureMapping("Windows.Media.SpeechSynthesis", "Speech", + "Speech synthesis family — review WinAppSDK/Windows.Media support before migrating.", sensitive: true), + new FeatureMapping("Windows.Media.Audio", "Audio", + "Audio graph/playback family — validate device access under desktop identity.", sensitive: true), + new FeatureMapping("Windows.Devices.Sensors", "Sensors", + "Sensor family — validate capabilities and device access under desktop identity.", sensitive: true), + new FeatureMapping("Windows.Devices.Geolocation", "Sensors", + "Geolocation family — requires the location capability and consent prompt.", sensitive: true), + new FeatureMapping("Windows.Devices.Bluetooth", "Sensors", + "Bluetooth family — validate radio/device access under desktop identity.", sensitive: true), + new FeatureMapping("Windows.Devices.PointOfService", "Sensors", + "Point-of-service device family — validate device access under desktop identity.", sensitive: true), + new FeatureMapping("Windows.Networking.Proximity", "Sensors", + "Proximity/NFC family — validate capability support before migrating.", sensitive: true), + + // ─── B1: phone-only families (no desktop equivalent → defer) ─── + new FeatureMapping("Windows.Phone", "Phone-only", + "Phone-only API surface — no desktop equivalent; defer or redesign."), + new FeatureMapping("Windows.ApplicationModel.Calls", "Phone-only", + "Phone-only calls API surface — no desktop equivalent; defer or redesign."), + new FeatureMapping("Windows.Gaming.Input", "Gamepad input", + "Gamepad virtual-key paths are not in WinAppSDK — defer or redesign.") ); public static readonly ImmutableDictionary ByNamespacePrefix = @@ -46,13 +74,21 @@ private static ImmutableDictionary BuildLookup() internal sealed class FeatureMapping { - public FeatureMapping(string uwpNamespacePrefix, string area, string note) + public FeatureMapping(string uwpNamespacePrefix, string area, string note, bool sensitive = false) { UwpNamespacePrefix = uwpNamespacePrefix; Area = area; Note = note; + Sensitive = sensitive; } public string UwpNamespacePrefix { get; } public string Area { get; } public string Note { get; } + + /// + /// True for sensitive feature families (media capture, speech, audio, sensors, …) that must be + /// migrated SEQUENTIALLY. Carried to the analyze driver as severity: sensitive. Ordinary + /// namespace-rename hints (e.g. Windows.UI.Xaml) leave this false. + /// + public bool Sensitive { get; } } diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/MigrationTiers.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/MigrationTiers.cs new file mode 100644 index 00000000..00ab2871 --- /dev/null +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/MigrationTiers.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace Microsoft.WindowsAppSDK.Analyzers; + +/// +/// Migration-severity tiers carried on a diagnostic's +/// bag. This is orthogonal to the Roslyn DiagnosticSeverity (all migration rules ship at +/// Warning): it lets the out-of-process analyze driver map a finding to the JSON contract's +/// severity field. +/// +/// startup-crash marks APIs that THROW at runtime if left unaddressed (e.g. view-scoped +/// GetForCurrentView family, DisplayRequest) — these crash the page to a blank window +/// and must never be resolved with a keep-comment. +/// +internal static class MigrationTiers +{ + /// Property key on Diagnostic.Properties. + public const string PropertyKey = "MigrationTier"; + + /// Runtime-crash tier value (maps to contract severity: startup-crash). + public const string StartupCrash = "startup-crash"; + + /// + /// Sensitive-family tier value (maps to contract severity: sensitive). Drives the + /// skill's SEQUENTIAL pacing. Carried on WUI1010 feature hints for the sensitive + /// families only (media-capture/speech/audio/sensors/…), NOT on ordinary namespace-rename + /// hints — so a plain Windows.UI.Xaml hint does not force sequential processing. + /// + public const string Sensitive = "sensitive"; + + /// + /// Property key carrying the detected UWP API (qualified name / namespace prefix) verbatim, so + /// the analyze driver reads it from Diagnostic.Properties instead of slicing the + /// (localizable) diagnostic message. + /// + public const string DetectedApiKey = "DetectedApi"; + + /// Property key carrying the migration feature area (WUI1010), for the same reason. + public const string FeatureAreaKey = "FeatureArea"; + + /// Ready-made properties bag for a startup-crash finding. + public static readonly ImmutableDictionary StartupCrashProperties = + ImmutableDictionary.Empty.Add(PropertyKey, StartupCrash); + + /// Ready-made properties bag for a sensitive-family finding. + public static readonly ImmutableDictionary SensitiveProperties = + ImmutableDictionary.Empty.Add(PropertyKey, Sensitive); + + /// + /// Builds a Diagnostic.Properties bag merging an optional migration tier with the + /// machine-readable finding data the analyze driver needs. Keeping this data on the property + /// bag (rather than parsing it back out of the message string) makes the driver robust to + /// message localization and wording changes. + /// + public static ImmutableDictionary Build( + string? tier = null, string? detectedApi = null, string? featureArea = null) + { + var b = ImmutableDictionary.Empty; + if (tier != null) b = b.Add(PropertyKey, tier); + if (detectedApi != null) b = b.Add(DetectedApiKey, detectedApi); + if (featureArea != null) b = b.Add(FeatureAreaKey, featureArea); + return b; + } +} diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ProjectContext.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ProjectContext.cs index 7ad11c86..2ecdb15f 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ProjectContext.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/ProjectContext.cs @@ -39,6 +39,16 @@ public static ProjectKind Detect(Compilation compilation, AnalyzerOptions? optio private static ProjectKind DetectCore(Compilation compilation, AnalyzerOptions? options) { + // Explicit override: the analyze/validate driver sets this global option when the caller + // passes --from-uwp. Forces migration rules to fire even on mostly-migrated target source + // (namespaces already rewritten, WinUI 3 manifest) so `validate` can catch API residue. + if (options?.AnalyzerConfigOptionsProvider?.GlobalOptions is { } global + && global.TryGetValue("build_property.WinUIMigrationFromUwp", out var forced) + && string.Equals(forced, "true", StringComparison.OrdinalIgnoreCase)) + { + return ProjectKind.MigratingFromUwp; + } + bool sawUwpUsing = false; bool sawWinAppSdkUsing = false; diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/ApiMappingAnalyzer.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/ApiMappingAnalyzer.cs index 024b69c0..9d4367c5 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/ApiMappingAnalyzer.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/ApiMappingAnalyzer.cs @@ -113,8 +113,12 @@ private static void AnalyzeUsing(SyntaxNodeAnalysisContext context) { if (ns!.StartsWith(feature.UwpNamespacePrefix, System.StringComparison.Ordinal)) { + var featureProps = MigrationTiers.Build( + tier: feature.Sensitive ? MigrationTiers.Sensitive : null, + detectedApi: feature.UwpNamespacePrefix, + featureArea: feature.Area); context.ReportDiagnostic(Diagnostic.Create( - FeatureHintRule, node.GetLocation(), + FeatureHintRule, node.GetLocation(), featureProps, feature.UwpNamespacePrefix, feature.Area, feature.Note)); break; } @@ -124,15 +128,18 @@ private static void AnalyzeUsing(SyntaxNodeAnalysisContext context) private static bool TryReport(SyntaxNodeAnalysisContext context, Location location, string key) { if (!ApiMappings.ByQualifiedName.TryGetValue(key, out var mapping)) return false; + var properties = MigrationTiers.Build( + tier: mapping.StartupCrash ? MigrationTiers.StartupCrash : null, + detectedApi: key); if (mapping.WinAppSdkReplacement != null) { context.ReportDiagnostic(Diagnostic.Create( - MappingMatchRule, location, key, mapping.WinAppSdkReplacement)); + MappingMatchRule, location, properties, key, mapping.WinAppSdkReplacement)); } else { context.ReportDiagnostic(Diagnostic.Create( - MappingNoEquivRule, location, key, mapping.LearnAnchor ?? "see migration guide")); + MappingNoEquivRule, location, properties, key, mapping.LearnAnchor ?? "see migration guide")); } return true; } diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/UwpApiAnalyzer.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/UwpApiAnalyzer.cs index e94ac021..bd0ec72b 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/UwpApiAnalyzer.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/UwpApiAnalyzer.cs @@ -14,7 +14,7 @@ namespace Microsoft.WindowsAppSDK.Analyzers.Rules; /// /// using Windows.UI.Xaml (use Microsoft.UI.Xaml). /// Window.Current / Application.Current.Window (UWP-only). -/// CoreDispatcher (use DispatcherQueue). +/// CoreDispatcher and DependencyObject.Dispatcher (null in WinUI 3; use DispatcherQueue). /// GetForCurrentView() (use HWND-based interop). /// /// @@ -41,8 +41,8 @@ public sealed class UwpApiAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor CoreDispatcherRule = new( DiagnosticIds.CoreDispatcher, - "CoreDispatcher is UWP-only", - "CoreDispatcher is UWP-only — use DispatcherQueue.TryEnqueue() in WinUI 3", + "CoreDispatcher / Dispatcher is UWP-only", + "CoreDispatcher is UWP-only and DependencyObject.Dispatcher is null in WinUI 3 (accessing its members throws NullReferenceException at launch) — use DispatcherQueue instead", DiagnosticCategories.Compatibility, DiagnosticSeverity.Warning, isEnabledByDefault: true, @@ -65,9 +65,19 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(AnalyzeUsingDirective, SyntaxKind.UsingDirective); - context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression); - context.RegisterSyntaxNodeAction(AnalyzeIdentifier, SyntaxKind.IdentifierName); + context.RegisterCompilationStartAction(start => + { + // The syntactic Dispatcher fallback (target rightmost name == "Dispatcher") is only + // safe in loose-source mode — raw UWP source with no WinUI/UWP metadata, i.e. the + // out-of-build driver path where symbols don't bind. When CoreDispatcher metadata IS + // present (any properly-referenced build) rely solely on the precise semantic path, so + // a user property merely named `Dispatcher` is never flagged. (see RULES.md WUI0003) + bool looseSource = start.Compilation.GetTypeByMetadataName("Windows.UI.Core.CoreDispatcher") is null; + + start.RegisterSyntaxNodeAction(AnalyzeUsingDirective, SyntaxKind.UsingDirective); + start.RegisterSyntaxNodeAction(ctx => AnalyzeMemberAccess(ctx, looseSource), SyntaxKind.SimpleMemberAccessExpression); + start.RegisterSyntaxNodeAction(AnalyzeIdentifier, SyntaxKind.IdentifierName); + }); } private static void AnalyzeUsingDirective(SyntaxNodeAnalysisContext context) @@ -80,7 +90,7 @@ private static void AnalyzeUsingDirective(SyntaxNodeAnalysisContext context) } } - private static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) + private static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context, bool looseSource) { var memberAccess = (MemberAccessExpressionSyntax)context.Node; var memberName = memberAccess.Name.Identifier.Text; @@ -116,10 +126,55 @@ private static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) context.ReportDiagnostic(Diagnostic.Create( GetForCurrentViewRule, memberAccess.GetLocation(), + MigrationTiers.StartupCrashProperties, memberAccess.Expression.ToString())); } + + // DependencyObject.Dispatcher (and CoreWindow.Dispatcher) return Windows.UI.Core.CoreDispatcher, + // which is null in WinUI 3 desktop apps. The property still compiles, so member access such as + // `Dispatcher.HasThreadAccess` or `this.Dispatcher.RunAsync(...)` is a build-clean, run-fail launch + // crash (NullReferenceException). Flag any member access whose target is that UWP Dispatcher property. + var targetExpr = memberAccess.Expression; + // Cheap name pre-filter before the semantic query: every true positive — semantic or + // syntactic — has a target whose rightmost name is exactly `Dispatcher`, so skip the + // GetSymbolInfo call on the ~all member accesses that can't match. (perf) + if (RightmostName(targetExpr) != "Dispatcher") return; + + var targetSymbol = context.SemanticModel.GetSymbolInfo(targetExpr).Symbol; + bool isUwpDispatcher = targetSymbol is not null + ? IsUwpDispatcherProperty(targetSymbol) + // Loose-source fallback: no WinUI/UWP metadata (the driver over raw source), so the + // symbol won't bind and we match syntactically on the `Dispatcher` target. Gated to + // loose-source only — in a real referenced build the semantic path above is + // authoritative and a user property merely named `Dispatcher` must not be flagged. + : looseSource; + if (isUwpDispatcher) + { + context.ReportDiagnostic(Diagnostic.Create( + CoreDispatcherRule, + targetExpr.GetLocation(), + MigrationTiers.StartupCrashProperties)); + } } + /// + /// True when is a Dispatcher property that returns a + /// Windows.UI.Core.CoreDispatcher — i.e. DependencyObject.Dispatcher or + /// CoreWindow.Dispatcher, both of which are null in WinUI 3 desktop apps. + /// + private static bool IsUwpDispatcherProperty(ISymbol? symbol) => + symbol is IPropertySymbol { Name: "Dispatcher" } prop && + prop.Type.Name == "CoreDispatcher"; + + /// Rightmost identifier of an expression: Dispatcher, this.Dispatcher and + /// x.Dispatcher all yield "Dispatcher"; DispatcherQueue yields "DispatcherQueue". + private static string? RightmostName(ExpressionSyntax expr) => expr switch + { + IdentifierNameSyntax id => id.Identifier.Text, + MemberAccessExpressionSyntax ma => ma.Name.Identifier.Text, + _ => null + }; + private static void AnalyzeIdentifier(SyntaxNodeAnalysisContext context) { var identifier = (IdentifierNameSyntax)context.Node; diff --git a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/XamlAnalyzer.cs b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/XamlAnalyzer.cs index 889260d7..0db16176 100644 --- a/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/XamlAnalyzer.cs +++ b/src/tools/winui-analyzer/Microsoft.WindowsAppSDK.Analyzers/Rules/XamlAnalyzer.cs @@ -21,6 +21,7 @@ namespace Microsoft.WindowsAppSDK.Analyzers.Rules; /// — x:Bind without Mode= (defaults to OneTime). /// — Converter={x:Null} crashes at runtime. /// — interactive control missing AutomationId. +/// — UWP-only control (Pivot/Hub/…) with no WinUI 3 equivalent. /// /// [DiagnosticAnalyzer(LanguageNames.CSharp)] @@ -56,6 +57,16 @@ public sealed class XamlAnalyzer : DiagnosticAnalyzer helpLinkUri: HelpLinks.For(DiagnosticIds.NullConverter), customTags: WellKnownDiagnosticTags.CompilationEnd); + private static readonly DiagnosticDescriptor UwpOnlyControlRule = new( + DiagnosticIds.UwpOnlyXamlControl, + "UWP-only XAML control", + "<{0}> is a UWP-only XAML control with no direct WinUI 3 equivalent — {1}", + DiagnosticCategories.Runtime, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinks.For(DiagnosticIds.UwpOnlyXamlControl), + customTags: WellKnownDiagnosticTags.CompilationEnd); + private static readonly DiagnosticDescriptor XBindNoModeRule = new( DiagnosticIds.XBindMissingMode, "x:Bind without Mode", @@ -67,7 +78,28 @@ public sealed class XamlAnalyzer : DiagnosticAnalyzer customTags: WellKnownDiagnosticTags.CompilationEnd); public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(NestedXBindRule, MissingAutomationIdRule, XBindNoModeRule, NullConverterRule); + ImmutableArray.Create(NestedXBindRule, MissingAutomationIdRule, XBindNoModeRule, NullConverterRule, UwpOnlyControlRule); + + /// The default WinUI/UWP XAML presentation namespace. WUI2003 only matches + /// controls declared in this namespace, so a same-named custom control in a using: + /// namespace is not a false positive. + private const string PresentationNamespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + + /// + /// UWP XAML controls that have no direct WinUI 3 equivalent. Matched by local element name + /// but only within (the default WinUI/UWP namespace that + /// migrating source still authors these controls in) so custom controls that merely reuse the + /// name in another namespace are not flagged. Value = short migration guidance surfaced in the + /// diagnostic message. + /// + private static readonly Dictionary UwpOnlyControls = new(StringComparer.Ordinal) + { + ["Pivot"] = "use NavigationView, TabView, or SelectorBar", + ["PivotItem"] = "migrate the parent Pivot to NavigationView/TabView items", + ["Hub"] = "use NavigationView or a custom scrolling layout", + ["HubSection"] = "migrate the parent Hub to a custom layout", + ["VirtualizingStackPanel"] = "use ItemsStackPanel or ItemsRepeater", + }; private static readonly HashSet InteractiveControls = new(StringComparer.OrdinalIgnoreCase) { @@ -118,6 +150,13 @@ private static void AnalyzeXamlFile( { var localName = element.Name.LocalName; + if (element.Name.NamespaceName == PresentationNamespace + && UwpOnlyControls.TryGetValue(localName, out var guidance)) + { + var location = CreateLocation(file, sourceText, element); + context.ReportDiagnostic(Diagnostic.Create(UwpOnlyControlRule, location, localName, guidance)); + } + if (InteractiveControls.Contains(localName)) { var hasAutomationId = element.Attributes().Any(a => diff --git a/src/tools/winui-analyzer/README.md b/src/tools/winui-analyzer/README.md index d0577060..d9a7f639 100644 --- a/src/tools/winui-analyzer/README.md +++ b/src/tools/winui-analyzer/README.md @@ -16,6 +16,7 @@ src/tools/winui-analyzer/ │ ├── Allowlists.cs # declarative per-rule carve-outs │ ├── ApiMappings.g.cs / FeatureMappings.g.cs # data-driven from Microsoft Learn │ └── Rules/ # 9 DiagnosticAnalyzers +├── Microsoft.WindowsAppSDK.Analyzers.Driver/ # `winui-analyze` out-of-build driver (net10.0) ├── Microsoft.WindowsAppSDK.Analyzers.Tests/ # xUnit test project (net10.0) ├── docs/ROADMAP.md # what's planned next ├── RULES.md # full rule catalog + ID methodology @@ -44,6 +45,34 @@ migration table from the older `WUIxxx` 3-digit scheme. | MVVM patterns | `WUI3xxx` | Old `[ObservableProperty]` field syntax | | Interop | `WUI4xxx` | `WebView2` not initialized, removed ONNX Runtime GenAI APIs | +## Migration analyze driver (`winui-analyze`) + +The analyzer normally runs **inside a build** as a compilation side-effect — which +requires compilable source. UWP source mid-migration does not compile against the +WinUI/.NET toolchain (foreign SDK, `Windows.UI.Xaml`, `uap:` manifest), so the +`Microsoft.WindowsAppSDK.Analyzers.Driver` project ships a standalone host, +`winui-analyze`, that runs the **same** analyzers over raw (non-compiling) source and +emits a machine-readable migration plan. + +```powershell +# Analyze a still-UWP source tree and write the plan to a file +winui-analyze --root path\to\uwp-app --from-uwp > migration-plan.json +``` + +* Discovers `*.cs` + XAML + `Package.appxmanifest` under `--root`, fabricates an + in-memory compilation (best-effort references the Windows SDK `Windows.winmd` so the + semantic `WUI1xxx` rules fire), runs all analyzers, and maps each diagnostic to the + **v1.0 JSON contract** on stdout. +* Each finding carries `id`, `severity` (`adaptable` / `sensitive` / `startup-crash` / + `unsupported`), the `detected` API, `location`, and an optional `fix`. Files get a + `disposition` (`migrate` / `sequential-manual` / `defer`). Finding data is read from + `Diagnostic.Properties`, not parsed from (localizable) message text. +* Because it embeds Roslyn it is **framework-dependent** (not NativeAOT) and runs + out-of-process — the intended consumer is the `winui-uwp-migration` skill's Step 0. + +Build it with the rest of the solution (`dotnet build … .slnx -c Release`); the driver's +`AssemblyName` is `winui-analyze`. + ## Building & testing Requires the .NET 10 SDK (a `global.json` in this directory pins to 10.0.x). diff --git a/src/tools/winui-analyzer/RULES.md b/src/tools/winui-analyzer/RULES.md index c09657b6..ca66e8dd 100644 --- a/src/tools/winui-analyzer/RULES.md +++ b/src/tools/winui-analyzer/RULES.md @@ -85,12 +85,12 @@ The analyzer takes false positives seriously — every guard below is testable. * **Why:** Doesn't exist in WinUI 3 desktop. Store the `Window` reference on `App`. * **Microsoft Learn:** [API mapping table](https://learn.microsoft.com/windows/apps/windows-app-sdk/migrate-to-windows-app-sdk/api-mapping-table) -### WUI0003 — `CoreDispatcher` is UWP-only -* **Category:** `WinUI.Compatibility` · **Severity:** `Warning` -* **Fires when:** A symbol resolves to `Windows.UI.Core.CoreDispatcher` (or unresolved `CoreDispatcher` in a type position). -* **Why:** Use `DispatcherQueue.TryEnqueue(...)` in WinUI 3. +### WUI0003 — `CoreDispatcher` / `Dispatcher` is UWP-only +* **Category:** `WinUI.Compatibility` · **Severity:** `Warning` (startup-crash tier for `Dispatcher` member access) +* **Fires when:** Either (a) a symbol resolves to `Windows.UI.Core.CoreDispatcher` (or unresolved `CoreDispatcher` in a type position); or (b) a member is accessed on the inherited `DependencyObject.Dispatcher` / `CoreWindow.Dispatcher` property — e.g. `Dispatcher.HasThreadAccess`, `this.Dispatcher.RunAsync(...)`. Detected by symbol (a `Dispatcher` property typed `CoreDispatcher`) when metadata is present, with a syntactic fallback (target's rightmost name is exactly `Dispatcher`) for the loose-source driver path (raw source) where symbols don't bind. +* **Why:** `DependencyObject.Dispatcher` returns `null` in WinUI 3 desktop apps, so `Dispatcher.*` member access compiles clean but throws `NullReferenceException` at launch (window never appears → run failure). Use `DispatcherQueue.TryEnqueue(...)` / `DispatcherQueue.HasThreadAccess` in WinUI 3. * **Microsoft Learn:** [API mapping table](https://learn.microsoft.com/windows/apps/windows-app-sdk/migrate-to-windows-app-sdk/api-mapping-table) -* **Known false-positive risk:** medium — relies on semantic resolution. If the WinUI references aren't loaded yet (early in build), we fall back to a syntactic `BaseTypeSyntax`/`TypeSyntax` heuristic. Suppress per-line if a user type is named `CoreDispatcher`. +* **Known false-positive risk:** low — the member-access path prefers the precise semantic check (a `Dispatcher` property typed `CoreDispatcher`). Its syntactic fallback (target's rightmost name is exactly `Dispatcher`) is gated to **loose-source** compilations only — those with no `Windows.UI.Core.CoreDispatcher` metadata, i.e. the out-of-build driver running over raw source — so a real referenced build never flags a user property merely named `Dispatcher`. `DispatcherQueue` never matches. Suppress per-line if a user type is literally named `CoreDispatcher`. ### WUI0004 — `GetForCurrentView` is UWP-only @@ -123,6 +123,12 @@ Informational only. When code uses any namespace listed in the [Microsoft Learn * **Category:** `WinUI.Runtime` · **Severity:** `Warning` * **Fires when:** XAML declares `` and the matching code-behind assigns a raw control to a tab item's `Content`. +### WUI2003 — UWP-only XAML control +* **Category:** `WinUI.Runtime` · **Severity:** `Warning` +* **Fires when:** A XAML file (`AdditionalFiles`, excluding `App.xaml`) declares — in the WinUI/UWP presentation namespace — a control with no direct WinUI 3 equivalent: `Pivot`, `PivotItem`, `Hub`, `HubSection`, or `VirtualizingStackPanel`. +* **Why:** These controls were removed in WinUI 3; XAML that references them will not load. The diagnostic names the replacement path (`Pivot`→`NavigationView`/`TabView`/`SelectorBar`, `Hub`→`NavigationView`/custom layout, `VirtualizingStackPanel`→`ItemsStackPanel`/`ItemsRepeater`). +* **False-positive guards:** Matches by local element name but only within the default presentation namespace (`http://schemas.microsoft.com/winfx/2006/xaml/presentation`), so a same-named custom control in a `using:` namespace does not fire. Reported at compilation-end with `WellKnownDiagnosticTags.CompilationEnd`. Suppress via `dotnet_diagnostic.WUI2003.severity = none` — a C#-source `#pragma` cannot reach a XAML `AdditionalFile` diagnostic. + ### WUI2010 — Nested `x:Bind` without `FallbackValue` * **Category:** `WinUI.Runtime` · **Severity:** `Warning` * **Fires when:** An `{x:Bind A.B.C}` path has 3+ segments and lacks `FallbackValue=`.