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=`.