From 75800e0816ab6ec538c846109c518bd6c5617056 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:13:48 +0000 Subject: [PATCH 1/2] Initial plan From 113d6f53743f0885107c5322e1a69aeacd70c2cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:31:46 +0000 Subject: [PATCH 2/2] Add RP0001 nullable reference type analyzer and tests --- Directory.Packages.props | 4 + ReactiveProperty.slnx | 6 + .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 8 + .../ReactiveProperty.Analyzer.csproj | 28 +++ .../ReactivePropertyNullableAnalyzer.cs | 199 ++++++++++++++++ .../ReactiveProperty.Core.csproj | 15 ++ .../ReactiveProperty.NETStandard.csproj | 12 + .../AnalyzerVerifier.cs | 68 ++++++ .../GlobalUsings.cs | 1 + .../ReactiveProperty.Analyzer.Tests.csproj | 27 +++ .../ReactivePropertyNullableAnalyzerTest.cs | 225 ++++++++++++++++++ ...0006-reactiveproperty-nullable-analyzer.md | 61 +++++ dev-docs/adr/README.md | 1 + 14 files changed, 657 insertions(+) create mode 100644 Source/ReactiveProperty.Analyzer/AnalyzerReleases.Shipped.md create mode 100644 Source/ReactiveProperty.Analyzer/AnalyzerReleases.Unshipped.md create mode 100644 Source/ReactiveProperty.Analyzer/ReactiveProperty.Analyzer.csproj create mode 100644 Source/ReactiveProperty.Analyzer/ReactivePropertyNullableAnalyzer.cs create mode 100644 Test/ReactiveProperty.Analyzer.Tests/AnalyzerVerifier.cs create mode 100644 Test/ReactiveProperty.Analyzer.Tests/GlobalUsings.cs create mode 100644 Test/ReactiveProperty.Analyzer.Tests/ReactiveProperty.Analyzer.Tests.csproj create mode 100644 Test/ReactiveProperty.Analyzer.Tests/ReactivePropertyNullableAnalyzerTest.cs create mode 100644 dev-docs/adr/0006-reactiveproperty-nullable-analyzer.md diff --git a/Directory.Packages.props b/Directory.Packages.props index b6f3b0bc..7077cf15 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,8 +23,12 @@ + + + + diff --git a/ReactiveProperty.slnx b/ReactiveProperty.slnx index beeceb3c..d1bb9560 100644 --- a/ReactiveProperty.slnx +++ b/ReactiveProperty.slnx @@ -61,6 +61,9 @@ + + + @@ -82,6 +85,9 @@ + + + diff --git a/Source/ReactiveProperty.Analyzer/AnalyzerReleases.Shipped.md b/Source/ReactiveProperty.Analyzer/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..f50bb1fe --- /dev/null +++ b/Source/ReactiveProperty.Analyzer/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/Source/ReactiveProperty.Analyzer/AnalyzerReleases.Unshipped.md b/Source/ReactiveProperty.Analyzer/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..6ecf9793 --- /dev/null +++ b/Source/ReactiveProperty.Analyzer/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +RP0001 | Usage | Warning | ReactivePropertyNullableAnalyzer diff --git a/Source/ReactiveProperty.Analyzer/ReactiveProperty.Analyzer.csproj b/Source/ReactiveProperty.Analyzer/ReactiveProperty.Analyzer.csproj new file mode 100644 index 00000000..ffabb180 --- /dev/null +++ b/Source/ReactiveProperty.Analyzer/ReactiveProperty.Analyzer.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + ReactiveProperty.Analyzer + Reactive.Bindings.Analyzer + Roslyn analyzer for ReactiveProperty that supports nullable reference types. + true + true + + false + false + false + true + ..\ReactiveProperty.Core\key.snk + + + + + + + + + + + + + diff --git a/Source/ReactiveProperty.Analyzer/ReactivePropertyNullableAnalyzer.cs b/Source/ReactiveProperty.Analyzer/ReactivePropertyNullableAnalyzer.cs new file mode 100644 index 00000000..a818a63f --- /dev/null +++ b/Source/ReactiveProperty.Analyzer/ReactivePropertyNullableAnalyzer.cs @@ -0,0 +1,199 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Reactive.Bindings.Analyzer; + +/// +/// Reports usages of ReactiveProperty family types for a non-nullable reference type that are +/// created without an initial value. In that situation the Value property is initialized to +/// null even though its type is a non-nullable reference type, which the C# compiler cannot +/// detect on its own. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ReactivePropertyNullableAnalyzer : DiagnosticAnalyzer +{ + /// + /// The diagnostic id reported by this analyzer. + /// + public const string DiagnosticId = "RP0001"; + + private const string Category = "Usage"; + + private static readonly DiagnosticDescriptor s_rule = new( + DiagnosticId, + "Provide an initial value or use a nullable type argument", + "'{0}' is created for the non-nullable reference type '{1}' without an initial value, so its Value will be null. Provide an initial value or use a nullable type argument such as '{1}?'.", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "ReactiveProperty family types initialize Value to default (null for reference types) when no initial value is supplied. The C# compiler cannot detect this, so this analyzer reports it for non-nullable reference types."); + + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(s_rule); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + var compilation = context.Compilation; + + var targetTypes = new[] + { + compilation.GetTypeByMetadataName("Reactive.Bindings.ReactiveProperty`1"), + compilation.GetTypeByMetadataName("Reactive.Bindings.ReactivePropertySlim`1"), + compilation.GetTypeByMetadataName("Reactive.Bindings.ReadOnlyReactivePropertySlim`1"), + compilation.GetTypeByMetadataName("Reactive.Bindings.ReadOnlyReactiveProperty`1"), + } + .Where(x => x is not null) + .Select(x => (ISymbol?)x!.OriginalDefinition) + .ToImmutableHashSet(SymbolEqualityComparer.Default); + + if (targetTypes.IsEmpty) + { + return; + } + + context.RegisterOperationAction(ctx => AnalyzeObjectCreation(ctx, targetTypes), OperationKind.ObjectCreation); + context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation); + } + + private static void AnalyzeObjectCreation(OperationAnalysisContext context, ImmutableHashSet targetTypes) + { + var operation = (IObjectCreationOperation)context.Operation; + if (operation.Type is not INamedTypeSymbol createdType || createdType.TypeArguments.Length != 1) + { + return; + } + + if (!targetTypes.Contains(createdType.OriginalDefinition)) + { + return; + } + + var typeArgument = createdType.TypeArguments[0]; + if (!IsNonNullableReferenceType(typeArgument) || HasInitialValue(operation.Arguments)) + { + return; + } + + Report(context, operation.Syntax.GetLocation(), createdType, typeArgument); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context) + { + var operation = (IInvocationOperation)context.Operation; + var method = operation.TargetMethod; + if (method.Name is not ("ToReadOnlyReactivePropertySlim" or "ToReadOnlyReactiveProperty")) + { + return; + } + + if (method.ContainingNamespace?.ToDisplayString() != "Reactive.Bindings") + { + return; + } + + if (operation.Type is not INamedTypeSymbol returnType) + { + return; + } + + // The compiler infers the return type argument as nullable because of the + // 'initialValue = default' parameter, so the element type is taken from the source + // observable instead, which reflects what the caller actually provided. + ITypeSymbol? typeArgument = null; + foreach (var argument in operation.Arguments) + { + typeArgument = GetObservableElementType(argument.Value.Type); + if (typeArgument is not null) + { + break; + } + } + + if (typeArgument is null || !IsNonNullableReferenceType(typeArgument) || HasInitialValue(operation.Arguments)) + { + return; + } + + Report(context, operation.Syntax.GetLocation(), returnType, typeArgument); + } + + private static ITypeSymbol? GetObservableElementType(ITypeSymbol? type) + { + if (type is null) + { + return null; + } + + if (type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.None && + named.OriginalDefinition.ToDisplayString() == "System.IObservable" && + named.TypeArguments.Length == 1) + { + return named.TypeArguments[0]; + } + + foreach (var @interface in type.AllInterfaces) + { + if (@interface.OriginalDefinition.ToDisplayString() == "System.IObservable" && + @interface.TypeArguments.Length == 1) + { + return @interface.TypeArguments[0]; + } + } + + return null; + } + + private static void Report(OperationAnalysisContext context, Location location, INamedTypeSymbol type, ITypeSymbol typeArgument) + { + var diagnostic = Diagnostic.Create( + s_rule, + location, + type.OriginalDefinition.Name, + typeArgument.ToDisplayString()); + context.ReportDiagnostic(diagnostic); + } + + private static bool HasInitialValue(ImmutableArray arguments) + { + foreach (var argument in arguments) + { + if (argument.Parameter?.Name == "initialValue") + { + return argument.ArgumentKind != ArgumentKind.DefaultValue; + } + } + + return false; + } + + private static bool IsNonNullableReferenceType(ITypeSymbol typeSymbol) + { + // Skip open type parameters (e.g. usages inside generic code) to avoid false positives. + if (typeSymbol.TypeKind == TypeKind.TypeParameter) + { + return false; + } + + // Only reference types can be null. + if (!typeSymbol.IsReferenceType) + { + return false; + } + + // NotAnnotated: nullable context is enabled and the type is non-nullable (e.g. string). + // Annotated (string?) and None (nullable context disabled) are intentionally ignored. + return typeSymbol.NullableAnnotation == NullableAnnotation.NotAnnotated; + } +} diff --git a/Source/ReactiveProperty.Core/ReactiveProperty.Core.csproj b/Source/ReactiveProperty.Core/ReactiveProperty.Core.csproj index d1a24f32..7144818a 100644 --- a/Source/ReactiveProperty.Core/ReactiveProperty.Core.csproj +++ b/Source/ReactiveProperty.Core/ReactiveProperty.Core.csproj @@ -12,4 +12,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Source/ReactiveProperty.NETStandard/ReactiveProperty.NETStandard.csproj b/Source/ReactiveProperty.NETStandard/ReactiveProperty.NETStandard.csproj index a536fd0c..c805d19d 100644 --- a/Source/ReactiveProperty.NETStandard/ReactiveProperty.NETStandard.csproj +++ b/Source/ReactiveProperty.NETStandard/ReactiveProperty.NETStandard.csproj @@ -16,6 +16,18 @@ + + + + + + + diff --git a/Test/ReactiveProperty.Analyzer.Tests/AnalyzerVerifier.cs b/Test/ReactiveProperty.Analyzer.Tests/AnalyzerVerifier.cs new file mode 100644 index 00000000..f58f52e1 --- /dev/null +++ b/Test/ReactiveProperty.Analyzer.Tests/AnalyzerVerifier.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace ReactiveProperty.Analyzer.Tests; + +internal static class AnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() +{ + public static DiagnosticResult Diagnostic(string diagnosticId) => + CSharpAnalyzerVerifier.Diagnostic(diagnosticId); + + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + }; + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } + + private sealed class Test : CSharpAnalyzerTest + { + public Test() + { + ReferenceAssemblies = new ReferenceAssemblies( + "net9.0", + new PackageIdentity("Microsoft.NETCore.App.Ref", "9.0.0"), + Path.Combine("ref", "net9.0")); + SolutionTransforms.Add((solution, projectId) => + { + var project = solution.GetProject(projectId)!; + var parseOptions = ((CSharpParseOptions)project.ParseOptions!).WithLanguageVersion(LanguageVersion.Latest); + var compilationOptions = ((CSharpCompilationOptions)project.CompilationOptions!) + .WithNullableContextOptions(NullableContextOptions.Enable); + + solution = solution + .WithProjectParseOptions(projectId, parseOptions) + .WithProjectCompilationOptions(projectId, compilationOptions); + + foreach (var reference in ReactivePropertyReferences.Value) + { + solution = solution.AddMetadataReference(projectId, reference); + } + + return solution; + }); + } + } + + private static readonly Lazy> ReactivePropertyReferences = new(() => + { + var assemblies = new[] + { + typeof(Reactive.Bindings.ReactivePropertySlim<>).Assembly, + typeof(Reactive.Bindings.ReactiveProperty<>).Assembly, + typeof(System.Reactive.Linq.Observable).Assembly, + }; + + return assemblies + .Select(x => (MetadataReference)MetadataReference.CreateFromFile(x.Location)) + .ToImmutableArray(); + }); +} diff --git a/Test/ReactiveProperty.Analyzer.Tests/GlobalUsings.cs b/Test/ReactiveProperty.Analyzer.Tests/GlobalUsings.cs new file mode 100644 index 00000000..974ab121 --- /dev/null +++ b/Test/ReactiveProperty.Analyzer.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/Test/ReactiveProperty.Analyzer.Tests/ReactiveProperty.Analyzer.Tests.csproj b/Test/ReactiveProperty.Analyzer.Tests/ReactiveProperty.Analyzer.Tests.csproj new file mode 100644 index 00000000..218ef6cc --- /dev/null +++ b/Test/ReactiveProperty.Analyzer.Tests/ReactiveProperty.Analyzer.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + ReactiveProperty.Analyzer.Tests + false + enable + enable + 14.0 + + + + + + + + + + + + + + + + + + diff --git a/Test/ReactiveProperty.Analyzer.Tests/ReactivePropertyNullableAnalyzerTest.cs b/Test/ReactiveProperty.Analyzer.Tests/ReactivePropertyNullableAnalyzerTest.cs new file mode 100644 index 00000000..44c41b29 --- /dev/null +++ b/Test/ReactiveProperty.Analyzer.Tests/ReactivePropertyNullableAnalyzerTest.cs @@ -0,0 +1,225 @@ +using Microsoft.CodeAnalysis.Testing; +using Reactive.Bindings.Analyzer; +using Verify = ReactiveProperty.Analyzer.Tests.AnalyzerVerifier; + +namespace ReactiveProperty.Analyzer.Tests; + +[TestClass] +public class ReactivePropertyNullableAnalyzerTest +{ + [TestMethod] + public async Task ReactiveProperty_NonNullableReferenceType_WithoutInitialValue_Reports() + { + var source = """ + using Reactive.Bindings; + class C + { + void M() + { + var x = {|#0:new ReactiveProperty()|}; + } + } + """; + var expected = Verify.Diagnostic(ReactivePropertyNullableAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("ReactiveProperty", "string"); + await Verify.VerifyAnalyzerAsync(source, expected); + } + + [TestMethod] + public async Task ReactivePropertySlim_NonNullableReferenceType_WithoutInitialValue_Reports() + { + var source = """ + using Reactive.Bindings; + class C + { + void M() + { + var x = {|#0:new ReactivePropertySlim()|}; + } + } + """; + var expected = Verify.Diagnostic(ReactivePropertyNullableAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("ReactivePropertySlim", "string"); + await Verify.VerifyAnalyzerAsync(source, expected); + } + + [TestMethod] + public async Task ReactivePropertySlim_WithModeButNoInitialValue_Reports() + { + var source = """ + using Reactive.Bindings; + class C + { + void M() + { + var x = {|#0:new ReactivePropertySlim(mode: ReactivePropertyMode.None)|}; + } + } + """; + var expected = Verify.Diagnostic(ReactivePropertyNullableAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("ReactivePropertySlim", "string"); + await Verify.VerifyAnalyzerAsync(source, expected); + } + + [TestMethod] + public async Task ReactivePropertySlim_WithInitialValue_DoesNotReport() + { + var source = """ + using Reactive.Bindings; + class C + { + void M() + { + var x = new ReactivePropertySlim("initial"); + } + } + """; + await Verify.VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task ReactivePropertySlim_NullableReferenceType_DoesNotReport() + { + var source = """ + using Reactive.Bindings; + class C + { + void M() + { + var x = new ReactivePropertySlim(); + } + } + """; + await Verify.VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task ReactivePropertySlim_ValueType_DoesNotReport() + { + var source = """ + using Reactive.Bindings; + class C + { + void M() + { + var x = new ReactivePropertySlim(); + } + } + """; + await Verify.VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task ReactivePropertySlim_NullableContextDisabled_DoesNotReport() + { + var source = """ + #nullable disable + using Reactive.Bindings; + class C + { + void M() + { + var x = new ReactivePropertySlim(); + } + } + """; + await Verify.VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task ReadOnlyReactivePropertySlim_Constructor_NonNullableReferenceType_Reports() + { + var source = """ + using System; + using Reactive.Bindings; + class C + { + void M(IObservable source) + { + var x = {|#0:new ReadOnlyReactivePropertySlim(source)|}; + } + } + """; + var expected = Verify.Diagnostic(ReactivePropertyNullableAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("ReadOnlyReactivePropertySlim", "string"); + await Verify.VerifyAnalyzerAsync(source, expected); + } + + [TestMethod] + public async Task ToReadOnlyReactivePropertySlim_NonNullableReferenceType_WithoutInitialValue_Reports() + { + var source = """ + using System; + using Reactive.Bindings; + class C + { + void M(IObservable source) + { + var x = {|#0:source.ToReadOnlyReactivePropertySlim()|}; + } + } + """; + var expected = Verify.Diagnostic(ReactivePropertyNullableAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("ReadOnlyReactivePropertySlim", "string"); + await Verify.VerifyAnalyzerAsync(source, expected); + } + + [TestMethod] + public async Task ToReadOnlyReactivePropertySlim_WithInitialValue_DoesNotReport() + { + var source = """ + using System; + using Reactive.Bindings; + class C + { + void M(IObservable source) + { + var x = source.ToReadOnlyReactivePropertySlim("initial"); + } + } + """; + await Verify.VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task ToReadOnlyReactivePropertySlim_NullableReferenceTypeSource_DoesNotReport() + { + var source = """ + using System; + using Reactive.Bindings; + class C + { + void M(IObservable source) + { + var x = source.ToReadOnlyReactivePropertySlim(); + } + } + """; + await Verify.VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task ToReadOnlyReactiveProperty_NonNullableReferenceType_WithoutInitialValue_Reports() + { + var source = """ + using System; + using Reactive.Bindings; + class C + { + void M(IObservable source) + { + var x = {|#0:source.ToReadOnlyReactiveProperty()|}; + } + } + """; + var expected = Verify.Diagnostic(ReactivePropertyNullableAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("ReadOnlyReactiveProperty", "string"); + await Verify.VerifyAnalyzerAsync(source, expected); + } +} diff --git a/dev-docs/adr/0006-reactiveproperty-nullable-analyzer.md b/dev-docs/adr/0006-reactiveproperty-nullable-analyzer.md new file mode 100644 index 00000000..4287c85b --- /dev/null +++ b/dev-docs/adr/0006-reactiveproperty-nullable-analyzer.md @@ -0,0 +1,61 @@ +# 0006. Ship a Roslyn analyzer (RP0001) for nullable reference type usage + +- **Status:** Accepted +- **Date:** 2026-07-08 +- **Deciders:** ReactiveProperty maintainers + +## Context + +ReactiveProperty enables nullable reference types (`enable`), but the C# +compiler cannot detect one important source of `null`: the `Value` of a `ReactiveProperty`, +`ReactivePropertySlim`, or `ReadOnlyReactivePropertySlim` is initialized to `default(T)` +when no initial value is supplied. For a non-nullable reference type such as `string`, this +leaves `Value` as `null` even though its declared type says it can never be `null`. + +For backward compatibility the parameterless / `initialValue = default` constructors must stay, +so the type system alone cannot express "you got a null here". This was tracked in issue #241, +where the community converged on a Roslyn analyzer as the way to add nullability support without a +breaking change. The compiler additionally infers the return type of +`ToReadOnlyReactivePropertySlim` / `ToReadOnlyReactiveProperty` as nullable (because of the +`initialValue = default` parameter), so the analyzer cannot rely on the inferred return type and +must inspect the source `IObservable` element type instead. + +## Decision + +We will add a `Reactive.Bindings.Analyzer.ReactivePropertyNullableAnalyzer` in a new +`Source/ReactiveProperty.Analyzer` project (netstandard2.0). It reports diagnostic **RP0001** +(category `Usage`, severity `Warning`) when: + +- `new ReactiveProperty(...)`, `new ReactivePropertySlim(...)`, or + `new ReadOnlyReactivePropertySlim(...)` is created without an `initialValue`, or +- `source.ToReadOnlyReactivePropertySlim(...)` / `source.ToReadOnlyReactiveProperty(...)` is + called without an `initialValue`, + +**and** the relevant type argument `T` is a non-nullable reference type (nullable context enabled, +annotation `NotAnnotated`). Value types, nullable annotated types (`T?`), open type parameters, +and code where the nullable context is disabled are ignored. + +The analyzer is packed into both the `ReactiveProperty.Core` and `ReactiveProperty` NuGet packages +under `analyzers/dotnet/cs`, so consumers of either package get it. It is only referenced with +`ReferenceOutputAssembly="false"` so it is not a runtime dependency. + +### Alternatives considered + +- **Do nothing / wait for the compiler** — the compiler still cannot detect this pattern and the + request in #241 remained open for years; rejected. +- **Add `[Obsolete]` to the default constructor** — rejected in #241 because it forces + `new ReactiveProperty(null)` and hurts the developer experience for the legitimate + `new ReactiveProperty()` usage. +- **Trust the inferred return type of the `ToReadOnly*` extension methods** — rejected because the + compiler infers it as nullable (`string?`) due to `initialValue = default`; the source + observable's element type is used instead. + +## Consequences + +- Consumers using nullable reference types get a clear, actionable warning at the exact call site, + with the fix spelled out (provide an initial value or use `T?`). +- No public API or runtime behavior changes; the analyzer only produces diagnostics. +- Two new projects are added: the analyzer and its MSTest test project (using + `Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.MSTest`). +- Future rules (for example, dedicated codefixes) can be added to the same analyzer package and + tracked via `AnalyzerReleases.Unshipped.md` / `AnalyzerReleases.Shipped.md`. diff --git a/dev-docs/adr/README.md b/dev-docs/adr/README.md index e79bb773..3179aa8b 100644 --- a/dev-docs/adr/README.md +++ b/dev-docs/adr/README.md @@ -39,3 +39,4 @@ Trivial, easily reversible choices (local refactors, naming) do not need an ADR. | [0003](./0003-reactiveproperty-r3-migration-bridge.md) | Ship a permanent minimal `ReactiveProperty.R3` migration bridge and a migration skill | Accepted | | [0004](./0004-reactiveproperty-r3-wpf-event-to-reactive.md) | Provide R3-targeting EventToReactive trigger actions in a new ReactiveProperty.R3.WPF package | Accepted | | [0005](./0005-remove-vuepress-plain-markdown-docs.md) | Remove VuePress; publish docs as plain Markdown on GitHub | Accepted | +| [0006](./0006-reactiveproperty-nullable-analyzer.md) | Ship a Roslyn analyzer (RP0001) for nullable reference type usage | Accepted |