diff --git a/src/BlazorWebFormsComponents.Analyzers.Test/AllAnalyzersIntegrationTests.cs b/src/BlazorWebFormsComponents.Analyzers.Test/AllAnalyzersIntegrationTests.cs index 6f999cc63..baf285672 100644 --- a/src/BlazorWebFormsComponents.Analyzers.Test/AllAnalyzersIntegrationTests.cs +++ b/src/BlazorWebFormsComponents.Analyzers.Test/AllAnalyzersIntegrationTests.cs @@ -41,6 +41,7 @@ public class AllAnalyzersIntegrationTests "BWFC021", // FindControlUsageAnalyzer "BWFC022", // PageClientScriptUsageAnalyzer "BWFC023", // IPostBackEventHandlerUsageAnalyzer + "BWFC025", // NonSerializableViewStateAnalyzer }; #region ID Registration and Uniqueness diff --git a/src/BlazorWebFormsComponents.Analyzers.Test/IsPostBackUsageAnalyzerTests.cs b/src/BlazorWebFormsComponents.Analyzers.Test/IsPostBackUsageAnalyzerTests.cs index b3598ee29..b48730bac 100644 --- a/src/BlazorWebFormsComponents.Analyzers.Test/IsPostBackUsageAnalyzerTests.cs +++ b/src/BlazorWebFormsComponents.Analyzers.Test/IsPostBackUsageAnalyzerTests.cs @@ -27,7 +27,7 @@ protected void DoInit() { } "; private static DiagnosticResult ExpectBWFC003(string memberName) => - new DiagnosticResult(IsPostBackUsageAnalyzer.DiagnosticId, DiagnosticSeverity.Warning) + new DiagnosticResult(IsPostBackUsageAnalyzer.DiagnosticId, DiagnosticSeverity.Info) .WithArguments(memberName); #region Positive cases — BWFC003 SHOULD fire diff --git a/src/BlazorWebFormsComponents.Analyzers.Test/NonSerializableViewStateAnalyzerTests.cs b/src/BlazorWebFormsComponents.Analyzers.Test/NonSerializableViewStateAnalyzerTests.cs new file mode 100644 index 000000000..f7596798c --- /dev/null +++ b/src/BlazorWebFormsComponents.Analyzers.Test/NonSerializableViewStateAnalyzerTests.cs @@ -0,0 +1,217 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace BlazorWebFormsComponents.Analyzers.Test; + +using AnalyzerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest< + NonSerializableViewStateAnalyzer, + DefaultVerifier>; + +/// +/// Tests for BWFC025: ViewState value may not be JSON-serializable. +/// +public class NonSerializableViewStateAnalyzerTests +{ + private const string StubSource = @" +using System; +using System.Collections.Generic; + +public class ViewStateDictionary : Dictionary +{ + public void Set(string key, T value) { this[key] = value; } +} + +public class PageBase +{ + public ViewStateDictionary ViewState { get; } = new ViewStateDictionary(); +} +"; + + private static DiagnosticResult ExpectBWFC025(string typeName) => + new DiagnosticResult(NonSerializableViewStateAnalyzer.DiagnosticId, DiagnosticSeverity.Warning) + .WithArguments(typeName); + + #region Negative cases — BWFC025 should NOT fire + + [Fact] + public async Task ViewState_AssignString_NoDiagnostic() + { + var source = @" +public class MyPage : PageBase +{ + public void Page_Load() + { + ViewState[""key""] = ""hello""; + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource } } + }; + await test.RunAsync(); + } + + [Fact] + public async Task ViewState_AssignInt_NoDiagnostic() + { + var source = @" +public class MyPage : PageBase +{ + public void Page_Load() + { + ViewState[""key""] = 42; + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource } } + }; + await test.RunAsync(); + } + + [Fact] + public async Task ViewState_AssignBool_NoDiagnostic() + { + var source = @" +public class MyPage : PageBase +{ + public void Page_Load() + { + ViewState[""key""] = true; + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource } } + }; + await test.RunAsync(); + } + + [Fact] + public async Task ViewState_SetGeneric_String_NoDiagnostic() + { + var source = @" +public class MyPage : PageBase +{ + public void Page_Load() + { + ViewState.Set(""key"", ""val""); + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource } } + }; + await test.RunAsync(); + } + + #endregion + + #region Positive cases — BWFC025 SHOULD fire + + [Fact] + public async Task ViewState_AssignDisposable_Diagnostic() + { + var source = @" +using System.IO; + +public class MyPage : PageBase +{ + public void Page_Load() + { + {|#0:ViewState[""key""] = new MemoryStream()|}; + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource } }, + ExpectedDiagnostics = { ExpectBWFC025("System.IO.MemoryStream").WithLocation(0) } + }; + await test.RunAsync(); + } + + [Fact] + public async Task ViewState_AssignDataTable_Diagnostic() + { + // Use a minimal stub for DataTable since System.Data is not referenced + var dataStub = @" +namespace System.Data +{ + public class DataTable + { + public string TableName { get; set; } + } +} +"; + + var source = @" +using System.Data; + +public class MyPage : PageBase +{ + public void Page_Load() + { + {|#0:ViewState[""key""] = new DataTable()|}; + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource, dataStub } }, + ExpectedDiagnostics = { ExpectBWFC025("System.Data.DataTable").WithLocation(0) } + }; + await test.RunAsync(); + } + + [Fact] + public async Task ViewState_AssignDelegate_Diagnostic() + { + var source = @" +using System; + +public class MyPage : PageBase +{ + public void Page_Load() + { + Action callback = () => { }; + {|#0:ViewState[""key""] = callback|}; + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource } }, + ExpectedDiagnostics = { ExpectBWFC025("System.Action").WithLocation(0) } + }; + await test.RunAsync(); + } + + [Fact] + public async Task ThisViewState_AssignDisposable_Diagnostic() + { + var source = @" +using System.IO; + +public class MyPage : PageBase +{ + public void Page_Load() + { + {|#0:this.ViewState[""key""] = new MemoryStream()|}; + } +}"; + + var test = new AnalyzerTest + { + TestState = { Sources = { source, StubSource } }, + ExpectedDiagnostics = { ExpectBWFC025("System.IO.MemoryStream").WithLocation(0) } + }; + await test.RunAsync(); + } + + #endregion +} diff --git a/src/BlazorWebFormsComponents.Analyzers.Test/ViewStateUsageAnalyzerTests.cs b/src/BlazorWebFormsComponents.Analyzers.Test/ViewStateUsageAnalyzerTests.cs index 088499000..e6d5523f9 100644 --- a/src/BlazorWebFormsComponents.Analyzers.Test/ViewStateUsageAnalyzerTests.cs +++ b/src/BlazorWebFormsComponents.Analyzers.Test/ViewStateUsageAnalyzerTests.cs @@ -27,7 +27,7 @@ public class PageBase "; private static DiagnosticResult ExpectBWFC002(string memberName) => - new DiagnosticResult(ViewStateUsageAnalyzer.DiagnosticId, DiagnosticSeverity.Warning) + new DiagnosticResult(ViewStateUsageAnalyzer.DiagnosticId, DiagnosticSeverity.Info) .WithArguments(memberName); #region Positive cases — BWFC002 SHOULD fire diff --git a/src/BlazorWebFormsComponents.Analyzers/AnalyzerReleases.Unshipped.md b/src/BlazorWebFormsComponents.Analyzers/AnalyzerReleases.Unshipped.md index f08e374e8..a3f26cae4 100644 --- a/src/BlazorWebFormsComponents.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/BlazorWebFormsComponents.Analyzers/AnalyzerReleases.Unshipped.md @@ -3,8 +3,8 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- BWFC001 | Usage | Warning | MissingParameterAttributeAnalyzer -BWFC002 | Usage | Warning | ViewStateUsageAnalyzer -BWFC003 | Usage | Warning | IsPostBackUsageAnalyzer +BWFC002 | Usage | Info | ViewStateUsageAnalyzer (was Warning — ViewState now works as migration shim) +BWFC003 | Usage | Info | IsPostBackUsageAnalyzer (was Warning — IsPostBack now works via BWFC shim) BWFC004 | Usage | Warning | ResponseRedirectAnalyzer BWFC005 | Usage | Warning | SessionUsageAnalyzer BWFC010 | Usage | Info | RequiredAttributeAnalyzer @@ -16,3 +16,4 @@ BWFC020 | Migration | Info | ViewStatePropertyPatternAnalyzer BWFC021 | Migration | Warning | FindControlUsageAnalyzer BWFC022 | Migration | Warning | PageClientScriptUsageAnalyzer BWFC023 | Migration | Warning | IPostBackEventHandlerUsageAnalyzer +BWFC025 | Usage | Warning | NonSerializableViewStateAnalyzer diff --git a/src/BlazorWebFormsComponents.Analyzers/IsPostBackUsageAnalyzer.cs b/src/BlazorWebFormsComponents.Analyzers/IsPostBackUsageAnalyzer.cs index f6a5a480a..33f10e168 100644 --- a/src/BlazorWebFormsComponents.Analyzers/IsPostBackUsageAnalyzer.cs +++ b/src/BlazorWebFormsComponents.Analyzers/IsPostBackUsageAnalyzer.cs @@ -8,7 +8,7 @@ namespace BlazorWebFormsComponents.Analyzers { /// /// Analyzer that detects IsPostBack and Page.IsPostBack usage patterns. - /// Blazor uses component lifecycle instead of the postback model. + /// IsPostBack works via the BWFC shim; suggests Blazor lifecycle methods for new code. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public class IsPostBackUsageAnalyzer : DiagnosticAnalyzer @@ -16,8 +16,8 @@ public class IsPostBackUsageAnalyzer : DiagnosticAnalyzer public const string DiagnosticId = "BWFC003"; private static readonly LocalizableString Title = "IsPostBack usage detected"; - private static readonly LocalizableString MessageFormat = "'{0}' checks IsPostBack \u2014 Blazor doesn't have postbacks. Use lifecycle methods (OnInitialized, OnParametersSet) instead."; - private static readonly LocalizableString Description = "Flags IsPostBack and Page.IsPostBack checks. Blazor uses component lifecycle instead of postback model."; + private static readonly LocalizableString MessageFormat = "'{0}' checks IsPostBack \u2014 this works via the BWFC shim (SSR: checks HTTP POST, Interactive: tracks initialization). For new code, prefer OnInitialized lifecycle."; + private static readonly LocalizableString Description = "Flags IsPostBack and Page.IsPostBack checks. IsPostBack works via the BWFC shim during migration; prefer Blazor lifecycle methods for new code."; private const string Category = "Usage"; private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( @@ -25,7 +25,7 @@ public class IsPostBackUsageAnalyzer : DiagnosticAnalyzer Title, MessageFormat, Category, - DiagnosticSeverity.Warning, + DiagnosticSeverity.Info, isEnabledByDefault: true, description: Description); diff --git a/src/BlazorWebFormsComponents.Analyzers/NonSerializableViewStateAnalyzer.cs b/src/BlazorWebFormsComponents.Analyzers/NonSerializableViewStateAnalyzer.cs new file mode 100644 index 000000000..dcb58edf4 --- /dev/null +++ b/src/BlazorWebFormsComponents.Analyzers/NonSerializableViewStateAnalyzer.cs @@ -0,0 +1,151 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Linq; + +namespace BlazorWebFormsComponents.Analyzers +{ + /// + /// Analyzer that detects ViewState assignments storing types that are + /// unlikely to be JSON-serializable, which will fail at runtime in SSR mode. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class NonSerializableViewStateAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticId = "BWFC025"; + + private static readonly LocalizableString Title = "ViewState value may not be JSON-serializable"; + private static readonly LocalizableString MessageFormat = "ViewState assignment stores '{0}' which may not be JSON-serializable. SSR ViewState uses System.Text.Json serialization \u2014 ensure the type has a parameterless constructor and public properties."; + private static readonly LocalizableString Description = "Detects ViewState assignments storing types that are unlikely to be JSON-serializable, such as IDisposable implementations, delegates, DataSet/DataTable, and System.Web types."; + private const string Category = "Usage"; + + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + DiagnosticId, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Description); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeAssignment, SyntaxKind.SimpleAssignmentExpression); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeAssignment(SyntaxNodeAnalysisContext context) + { + var assignment = (AssignmentExpressionSyntax)context.Node; + + // Check if left side is ViewState["key"] or this.ViewState["key"] + if (!(assignment.Left is ElementAccessExpressionSyntax elementAccess)) + return; + + if (!IsViewStateAccess(elementAccess)) + return; + + var typeInfo = context.SemanticModel.GetTypeInfo(assignment.Right, context.CancellationToken); + if (typeInfo.Type != null && IsLikelyNonSerializable(typeInfo.Type)) + { + var diagnostic = Diagnostic.Create(Rule, assignment.GetLocation(), typeInfo.Type.ToDisplayString()); + context.ReportDiagnostic(diagnostic); + } + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Check for ViewState.Set("key", value) or this.ViewState.Set("key", value) + if (!(invocation.Expression is MemberAccessExpressionSyntax memberAccess)) + return; + + if (memberAccess.Name.Identifier.Text != "Set") + return; + + if (!IsViewStateExpression(memberAccess.Expression)) + return; + + // Get the type argument from Set + if (memberAccess.Name is GenericNameSyntax genericName && + genericName.TypeArgumentList.Arguments.Count == 1) + { + var typeArg = genericName.TypeArgumentList.Arguments[0]; + var typeSymbol = context.SemanticModel.GetTypeInfo(typeArg, context.CancellationToken).Type; + if (typeSymbol != null && IsLikelyNonSerializable(typeSymbol)) + { + var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), typeSymbol.ToDisplayString()); + context.ReportDiagnostic(diagnostic); + } + } + else if (invocation.ArgumentList.Arguments.Count >= 2) + { + // Non-generic Set("key", value) — check the value argument type + var valueArg = invocation.ArgumentList.Arguments[1]; + var typeInfo = context.SemanticModel.GetTypeInfo(valueArg.Expression, context.CancellationToken); + if (typeInfo.Type != null && IsLikelyNonSerializable(typeInfo.Type)) + { + var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), typeInfo.Type.ToDisplayString()); + context.ReportDiagnostic(diagnostic); + } + } + } + + private static bool IsViewStateAccess(ElementAccessExpressionSyntax elementAccess) + { + return IsViewStateExpression(elementAccess.Expression); + } + + private static bool IsViewStateExpression(ExpressionSyntax expr) + { + // ViewState + if (expr is IdentifierNameSyntax identifier) + return identifier.Identifier.Text == "ViewState"; + + // this.ViewState + if (expr is MemberAccessExpressionSyntax memberAccess) + return memberAccess.Name.Identifier.Text == "ViewState" && + memberAccess.Expression is ThisExpressionSyntax; + + return false; + } + + private static bool IsLikelyNonSerializable(ITypeSymbol type) + { + // Delegates and event handlers are never serializable + if (type.TypeKind == TypeKind.Delegate) + return true; + + // Check for IDisposable (DB connections, streams, etc.) + if (type.AllInterfaces.Any(i => i.Name == "IDisposable")) + return true; + + var fullName = type.ToDisplayString(); + + // System.Data types (DataSet, DataTable, etc.) — common Web Forms pattern, not JSON-friendly + if (fullName.StartsWith("System.Data.")) + return true; + + // System.Web types — not available in Blazor + if (fullName.StartsWith("System.Web.")) + return true; + + // Stream types + if (fullName.StartsWith("System.IO.Stream") || fullName == "System.IO.MemoryStream" || fullName == "System.IO.FileStream") + return true; + + // Network types + if (fullName.StartsWith("System.Net.")) + return true; + + return false; + } + } +} diff --git a/src/BlazorWebFormsComponents.Analyzers/ViewStatePropertyPatternAnalyzer.cs b/src/BlazorWebFormsComponents.Analyzers/ViewStatePropertyPatternAnalyzer.cs index 877f3eb56..1e3959760 100644 --- a/src/BlazorWebFormsComponents.Analyzers/ViewStatePropertyPatternAnalyzer.cs +++ b/src/BlazorWebFormsComponents.Analyzers/ViewStatePropertyPatternAnalyzer.cs @@ -9,7 +9,7 @@ namespace BlazorWebFormsComponents.Analyzers { /// /// Analyzer that detects properties using ViewState for backing storage. - /// These should be converted to [Parameter] properties for Blazor compatibility. + /// These work during migration; suggests converting to [Parameter] properties when ready. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public class ViewStatePropertyPatternAnalyzer : DiagnosticAnalyzer @@ -17,8 +17,8 @@ public class ViewStatePropertyPatternAnalyzer : DiagnosticAnalyzer public const string DiagnosticId = "BWFC020"; private static readonly LocalizableString Title = "ViewState-backed property detected"; - private static readonly LocalizableString MessageFormat = "Property '{0}' uses ViewState for storage. Convert to a [Parameter] property for Blazor compatibility."; - private static readonly LocalizableString Description = "Properties that use ViewState for backing storage should be converted to auto-properties with [Parameter] for Blazor."; + private static readonly LocalizableString MessageFormat = "Property '{0}' uses ViewState for storage — this works during migration. Consider converting to a [Parameter] property or component field when ready to adopt native Blazor patterns."; + private static readonly LocalizableString Description = "Properties that use ViewState for backing storage work during migration. Consider converting to auto-properties with [Parameter] when adopting native Blazor patterns."; private const string Category = "Migration"; private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( diff --git a/src/BlazorWebFormsComponents.Analyzers/ViewStateUsageAnalyzer.cs b/src/BlazorWebFormsComponents.Analyzers/ViewStateUsageAnalyzer.cs index 4bbe038c0..c512c6290 100644 --- a/src/BlazorWebFormsComponents.Analyzers/ViewStateUsageAnalyzer.cs +++ b/src/BlazorWebFormsComponents.Analyzers/ViewStateUsageAnalyzer.cs @@ -8,7 +8,7 @@ namespace BlazorWebFormsComponents.Analyzers { /// /// Analyzer that detects ViewState["key"] usage patterns. - /// Blazor has no ViewState; suggest using component state instead. + /// ViewState works as a migration shim; suggests native Blazor state for new code. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public class ViewStateUsageAnalyzer : DiagnosticAnalyzer @@ -16,8 +16,8 @@ public class ViewStateUsageAnalyzer : DiagnosticAnalyzer public const string DiagnosticId = "BWFC002"; private static readonly LocalizableString Title = "ViewState usage detected"; - private static readonly LocalizableString MessageFormat = "'{0}' uses ViewState \u2014 Blazor has no ViewState. Use component state (fields/properties) instead."; - private static readonly LocalizableString Description = "Flags ViewState[\"key\"] patterns in code. Blazor has no ViewState; use component state instead."; + private static readonly LocalizableString MessageFormat = "'{0}' uses ViewState \u2014 this works as a migration shim via ViewStateDictionary. For new code, prefer component fields or [Parameter] properties."; + private static readonly LocalizableString Description = "Flags ViewState[\"key\"] patterns in code. ViewState works during migration via ViewStateDictionary; prefer native Blazor state for new code."; private const string Category = "Usage"; private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( @@ -25,7 +25,7 @@ public class ViewStateUsageAnalyzer : DiagnosticAnalyzer Title, MessageFormat, Category, - DiagnosticSeverity.Warning, + DiagnosticSeverity.Info, isEnabledByDefault: true, description: Description);