From dadc728b28e9d82fe4409b1154c1c86de09bed6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:00:03 +0000 Subject: [PATCH 01/15] Initial plan From 37920ed0383ece7d978e93eb50868b7ece919f12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:19:38 +0000 Subject: [PATCH 02/15] Add local function detection to RenderTreeBuilder analyzer Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/Analyzers/DiagnosticDescriptors.cs | 9 + .../RenderTreeBuilderAnalyzer.cs | 78 ++++++- .../src/Analyzers/Resources.resx | 6 + .../DoNotUseLocalFunctionsInMarkupTest.cs | 196 ++++++++++++++++++ 4 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs index e48dd9445bfc..21e7f7954394 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs @@ -248,4 +248,13 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Info, isEnabledByDefault: true, helpLinkUri: AnalyzersLink); + + internal static readonly DiagnosticDescriptor DoNotUseLocalFunctionsInMarkup = new( + "ASP0029", + CreateLocalizableResourceString(nameof(Resources.Analyzer_DoNotUseLocalFunctionsInMarkup_Title)), + CreateLocalizableResourceString(nameof(Resources.Analyzer_DoNotUseLocalFunctionsInMarkup_Message)), + Usage, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: AnalyzersLink); } diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs index 76c41aa8f066..50ce0911eb45 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Linq; using Microsoft.AspNetCore.App.Analyzers.Infrastructure; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; @@ -16,7 +18,9 @@ namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; public partial class RenderTreeBuilderAnalyzer : DiagnosticAnalyzer { private const int SequenceParameterOrdinal = 0; - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.DoNotUseNonLiteralSequenceNumbers); + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptors.DoNotUseNonLiteralSequenceNumbers, + DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); public override void Initialize(AnalysisContext context) { @@ -53,6 +57,21 @@ public override void Initialize(AnalysisContext context) } }, OperationKind.Invocation); + + // Register syntax node action to detect local functions + context.RegisterSyntaxNodeAction(context => + { + var localFunction = (LocalFunctionStatementSyntax)context.Node; + + // Check if this local function contains any RenderTreeBuilder method calls + if (ContainsRenderTreeBuilderCalls(wellKnownTypes, localFunction, context.SemanticModel)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup, + localFunction.Identifier.GetLocation(), + localFunction.Identifier.ValueText)); + } + }, SyntaxKind.LocalFunctionStatement); }); } @@ -60,4 +79,61 @@ private static bool IsRenderTreeBuilderMethodWithSequenceParameter(WellKnownType => SymbolEqualityComparer.Default.Equals(wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Components_Rendering_RenderTreeBuilder), targetMethod.ContainingType) && targetMethod.Parameters.Length > SequenceParameterOrdinal && targetMethod.Parameters[SequenceParameterOrdinal].Name == "sequence"; + + private static bool ContainsRenderTreeBuilderCalls(WellKnownTypes wellKnownTypes, LocalFunctionStatementSyntax localFunction, SemanticModel semanticModel) + { + var renderTreeBuilderType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Components_Rendering_RenderTreeBuilder); + if (renderTreeBuilderType is null) + { + return false; + } + + // Static local functions cannot capture from enclosing scope, so they're safe + if (localFunction.Modifiers.Any(SyntaxKind.StaticKeyword)) + { + return false; + } + + // Walk through all invocation expressions in the local function + var invocations = localFunction.DescendantNodes().OfType(); + + foreach (var invocation in invocations) + { + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is IMethodSymbol method && + SymbolEqualityComparer.Default.Equals(renderTreeBuilderType, method.ContainingType)) + { + // Check if this is a call on a captured variable (not a parameter) + if (IsCallOnCapturedRenderTreeBuilder(invocation, localFunction, semanticModel, renderTreeBuilderType)) + { + return true; + } + } + } + + return false; + } + + private static bool IsCallOnCapturedRenderTreeBuilder(InvocationExpressionSyntax invocation, LocalFunctionStatementSyntax localFunction, SemanticModel semanticModel, INamedTypeSymbol _) + { + // Get the expression that the method is being called on + var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; + if (memberAccess is null) + { + return false; + } + + var targetSymbol = semanticModel.GetSymbolInfo(memberAccess.Expression).Symbol; + + // If it's a parameter of the local function, it's not captured + if (targetSymbol is IParameterSymbol parameter) + { + // Check if this parameter belongs to our local function + var localFunctionSymbol = semanticModel.GetDeclaredSymbol(localFunction); + return localFunctionSymbol is not null && !localFunctionSymbol.Parameters.Contains(parameter, SymbolEqualityComparer.Default); + } + + // If it's a local variable or field, it could be captured + return targetSymbol is IFieldSymbol or ILocalSymbol; + } } diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx index 8c9397f5be64..aa67b460dd15 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx @@ -333,4 +333,10 @@ If the server does not specifically reject IPv6, IPAddress.IPv6Any is preferred over IPAddress.Any usage for safety and performance reasons. See https://aka.ms/aspnetcore-warnings/ASP0028 for more details. + + Do not use local functions in markup + + + Local function '{0}' accesses RenderTreeBuilder from parent scope, which can cause incorrect rendering behavior. Consider making it a static method or regular instance method that takes RenderTreeBuilder as a parameter. + diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs new file mode 100644 index 000000000000..6ca79755de86 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -0,0 +1,196 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Linq; +using Microsoft.AspNetCore.Analyzer.Testing; + +namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; + +public class DoNotUseLocalFunctionsInMarkupTest +{ + private TestDiagnosticAnalyzerRunner Runner { get; } = new(new RenderTreeBuilderAnalyzer()); + + [Fact] + public async Task LocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() + { + // Arrange + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components.Rendering; + +var builder = new RenderTreeBuilder(); + +/*MM*/void LocalFunction() +{ + builder.OpenElement(0, ""div""); + builder.CloseElement(); +} + +LocalFunction(); +"); + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.NotNull(analyzerDiagnostic); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task LocalFunctionWithMultipleRenderTreeBuilderCalls_ProducesDiagnostic() + { + // Arrange + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components.Rendering; + +var builder = new RenderTreeBuilder(); + +/*MM*/void LocalFunction() +{ + builder.OpenElement(0, ""div""); + builder.AddContent(1, ""text""); + builder.CloseElement(); +} + +LocalFunction(); +"); + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.NotNull(analyzerDiagnostic); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task LocalFunctionWithoutRenderTreeBuilderCall_NoDiagnostic() + { + // Arrange + var source = @" +void LocalFunction() +{ + var x = 5; + System.Console.WriteLine(x); +} + +LocalFunction(); +"; + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithParameterRenderTreeBuilder_NoDiagnostic() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Components.Rendering; + +void LocalFunction(RenderTreeBuilder builder) +{ + builder.OpenElement(0, ""div""); + builder.CloseElement(); +} + +var builder = new RenderTreeBuilder(); +LocalFunction(builder); +"; + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task NestedLocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() + { + // Arrange + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components.Rendering; + +var builder = new RenderTreeBuilder(); + +void OuterFunction() +{ + /*MM*/void InnerFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + InnerFunction(); +} + +OuterFunction(); +"); + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.NotNull(analyzerDiagnostic); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + Assert.StartsWith("Local function 'InnerFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task StaticLocalFunction_NoDiagnostic() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Components.Rendering; + +var builder = new RenderTreeBuilder(); + +static void LocalFunction(RenderTreeBuilder builderParam) +{ + builderParam.OpenElement(0, ""div""); + builderParam.CloseElement(); +} + +LocalFunction(builder); +"; + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithMethodInvocation_ProducesDiagnostic() + { + // Arrange + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components.Rendering; + +var builder = new RenderTreeBuilder(); + +/*MM*/void LocalFunction() +{ + builder.AddMarkupContent(0, ""
Hello
""); +} + +LocalFunction(); +"); + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.NotNull(analyzerDiagnostic); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } +} \ No newline at end of file From c9727897e8ff84e69a6eb944e41d9f63ee029961 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:28:00 +0000 Subject: [PATCH 03/15] Complete implementation of local function detection in RenderTreeBuilder analyzer Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupTest.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 6ca79755de86..9ac0c488b35c 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -20,7 +20,7 @@ public async Task LocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() var builder = new RenderTreeBuilder(); -/*MM*/void LocalFunction() +void /*MM*/LocalFunction() { builder.OpenElement(0, ""div""); builder.CloseElement(); @@ -47,7 +47,7 @@ public async Task LocalFunctionWithMultipleRenderTreeBuilderCalls_ProducesDiagno var builder = new RenderTreeBuilder(); -/*MM*/void LocalFunction() +void /*MM*/LocalFunction() { builder.OpenElement(0, ""div""); builder.AddContent(1, ""text""); @@ -122,7 +122,7 @@ public async Task NestedLocalFunctionWithRenderTreeBuilderCall_ProducesDiagnosti void OuterFunction() { - /*MM*/void InnerFunction() + void /*MM*/InnerFunction() { builder.OpenElement(0, ""div""); builder.CloseElement(); @@ -137,10 +137,11 @@ void OuterFunction() var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); // Assert - var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); - Assert.NotNull(analyzerDiagnostic); - AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); - Assert.StartsWith("Local function 'InnerFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup).ToList(); + var innerFunctionDiagnostic = analyzerDiagnostics.FirstOrDefault(d => d.GetMessage(CultureInfo.InvariantCulture).Contains("InnerFunction")); + Assert.NotNull(innerFunctionDiagnostic); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, innerFunctionDiagnostic.Location); + Assert.StartsWith("Local function 'InnerFunction' accesses RenderTreeBuilder from parent scope", innerFunctionDiagnostic.GetMessage(CultureInfo.InvariantCulture)); } [Fact] @@ -177,7 +178,7 @@ public async Task LocalFunctionWithMethodInvocation_ProducesDiagnostic() var builder = new RenderTreeBuilder(); -/*MM*/void LocalFunction() +void /*MM*/LocalFunction() { builder.AddMarkupContent(0, ""
Hello
""); } From 36e3eba903d371f5ea1392eff522f9cc8c6a89af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:14:13 +0000 Subject: [PATCH 04/15] Scope local function analysis to BuildRenderTree methods in ComponentBase classes Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../RenderTreeBuilderAnalyzer.cs | 59 +++++- .../DoNotUseLocalFunctionsInMarkupTest.cs | 182 +++++++++++++----- 2 files changed, 187 insertions(+), 54 deletions(-) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs index 50ce0911eb45..ed0268eebde8 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs @@ -58,20 +58,45 @@ public override void Initialize(AnalysisContext context) }, OperationKind.Invocation); - // Register syntax node action to detect local functions + // Get ComponentBase type for scoping local function analysis + var componentBaseType = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.ComponentBase"); + + // Register syntax node action to detect local functions within BuildRenderTree methods context.RegisterSyntaxNodeAction(context => { - var localFunction = (LocalFunctionStatementSyntax)context.Node; - - // Check if this local function contains any RenderTreeBuilder method calls - if (ContainsRenderTreeBuilderCalls(wellKnownTypes, localFunction, context.SemanticModel)) + var methodDeclaration = (MethodDeclarationSyntax)context.Node; + + // Only analyze BuildRenderTree methods + if (methodDeclaration.Identifier.ValueText != "BuildRenderTree") + { + return; + } + + // Check if the containing type extends ComponentBase + var methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodDeclaration); + if (methodSymbol?.ContainingType == null || componentBaseType == null) + { + return; + } + + if (!InheritsFromComponentBase(methodSymbol.ContainingType, componentBaseType)) + { + return; + } + + // Now check for local functions within this BuildRenderTree method + var localFunctions = methodDeclaration.DescendantNodes().OfType(); + foreach (var localFunction in localFunctions) { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup, - localFunction.Identifier.GetLocation(), - localFunction.Identifier.ValueText)); + if (ContainsRenderTreeBuilderCalls(wellKnownTypes, localFunction, context.SemanticModel)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup, + localFunction.Identifier.GetLocation(), + localFunction.Identifier.ValueText)); + } } - }, SyntaxKind.LocalFunctionStatement); + }, SyntaxKind.MethodDeclaration); }); } @@ -80,6 +105,20 @@ private static bool IsRenderTreeBuilderMethodWithSequenceParameter(WellKnownType && targetMethod.Parameters.Length > SequenceParameterOrdinal && targetMethod.Parameters[SequenceParameterOrdinal].Name == "sequence"; + private static bool InheritsFromComponentBase(INamedTypeSymbol type, INamedTypeSymbol componentBaseType) + { + var current = type.BaseType; + while (current != null) + { + if (SymbolEqualityComparer.Default.Equals(current, componentBaseType)) + { + return true; + } + current = current.BaseType; + } + return false; + } + private static bool ContainsRenderTreeBuilderCalls(WellKnownTypes wellKnownTypes, LocalFunctionStatementSyntax localFunction, SemanticModel semanticModel) { var renderTreeBuilderType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Components_Rendering_RenderTreeBuilder); diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 9ac0c488b35c..7b417a5d3301 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -16,17 +16,22 @@ public async Task LocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() { // Arrange var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -var builder = new RenderTreeBuilder(); - -void /*MM*/LocalFunction() +public class TestComponent : ComponentBase { - builder.OpenElement(0, ""div""); - builder.CloseElement(); + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } } - -LocalFunction(); "); // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); @@ -43,18 +48,23 @@ public async Task LocalFunctionWithMultipleRenderTreeBuilderCalls_ProducesDiagno { // Arrange var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -var builder = new RenderTreeBuilder(); - -void /*MM*/LocalFunction() +public class TestComponent : ComponentBase { - builder.OpenElement(0, ""div""); - builder.AddContent(1, ""text""); - builder.CloseElement(); + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.AddContent(1, ""text""); + builder.CloseElement(); + } + + LocalFunction(); + } } - -LocalFunction(); "); // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); @@ -92,16 +102,22 @@ public async Task LocalFunctionWithParameterRenderTreeBuilder_NoDiagnostic() { // Arrange var source = @" +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -void LocalFunction(RenderTreeBuilder builder) +public class TestComponent : ComponentBase { - builder.OpenElement(0, ""div""); - builder.CloseElement(); + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void LocalFunction(RenderTreeBuilder builderParam) + { + builderParam.OpenElement(0, ""div""); + builderParam.CloseElement(); + } + + LocalFunction(builder); + } } - -var builder = new RenderTreeBuilder(); -LocalFunction(builder); "; // Act var diagnostics = await Runner.GetDiagnosticsAsync(source); @@ -116,22 +132,27 @@ public async Task NestedLocalFunctionWithRenderTreeBuilderCall_ProducesDiagnosti { // Arrange var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -var builder = new RenderTreeBuilder(); - -void OuterFunction() +public class TestComponent : ComponentBase { - void /*MM*/InnerFunction() + protected override void BuildRenderTree(RenderTreeBuilder builder) { - builder.OpenElement(0, ""div""); - builder.CloseElement(); + void OuterFunction() + { + void /*MM*/InnerFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + InnerFunction(); + } + + OuterFunction(); } - - InnerFunction(); } - -OuterFunction(); "); // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); @@ -149,17 +170,22 @@ public async Task StaticLocalFunction_NoDiagnostic() { // Arrange var source = @" +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -var builder = new RenderTreeBuilder(); - -static void LocalFunction(RenderTreeBuilder builderParam) +public class TestComponent : ComponentBase { - builderParam.OpenElement(0, ""div""); - builderParam.CloseElement(); + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + static void LocalFunction(RenderTreeBuilder builderParam) + { + builderParam.OpenElement(0, ""div""); + builderParam.CloseElement(); + } + + LocalFunction(builder); + } } - -LocalFunction(builder); "; // Act var diagnostics = await Runner.GetDiagnosticsAsync(source); @@ -174,16 +200,21 @@ public async Task LocalFunctionWithMethodInvocation_ProducesDiagnostic() { // Arrange var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -var builder = new RenderTreeBuilder(); - -void /*MM*/LocalFunction() +public class TestComponent : ComponentBase { - builder.AddMarkupContent(0, ""
Hello
""); + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.AddMarkupContent(0, ""
Hello
""); + } + + LocalFunction(); + } } - -LocalFunction(); "); // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); @@ -194,4 +225,67 @@ public async Task LocalFunctionWithMethodInvocation_ProducesDiagnostic() AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); } + + [Fact] + public async Task LocalFunctionOutsideComponentBase_NoDiagnostic() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Components.Rendering; + +public class NotAComponent +{ + public void SomeMethod() + { + var builder = new RenderTreeBuilder(); + + void LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"; + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionInNonBuildRenderTreeMethod_NoDiagnostic() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + private void SomeOtherMethod() + { + var builder = new RenderTreeBuilder(); + + void LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"; + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } } \ No newline at end of file From 504b6b717aa0df6c04887492d612c9a20e6911a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:39:08 +0000 Subject: [PATCH 05/15] Merge current main for ASP0039 analyzer Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/submodules/MessagePack-CSharp | 2 +- src/submodules/googletest | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/submodules/MessagePack-CSharp b/src/submodules/MessagePack-CSharp index 365965f0d8c1..9aeb12b9bdb0 160000 --- a/src/submodules/MessagePack-CSharp +++ b/src/submodules/MessagePack-CSharp @@ -1 +1 @@ -Subproject commit 365965f0d8c13c40ff8fde25882066b90f569c7a +Subproject commit 9aeb12b9bdb024512ffe2e4bddfa2785dca6e39e diff --git a/src/submodules/googletest b/src/submodules/googletest index d89aac5f0dd4..50b8600c63c5 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit d89aac5f0dd4021198d903d39de16f896726de21 +Subproject commit 50b8600c63c5487e901e2845a0f64d384a65f75d From 90f043e5cb137ca34ac1c5b249634ee7f4883af4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:40:14 +0000 Subject: [PATCH 06/15] Restore submodule pointers from main Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/submodules/MessagePack-CSharp | 2 +- src/submodules/googletest | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/submodules/MessagePack-CSharp b/src/submodules/MessagePack-CSharp index 9aeb12b9bdb0..365965f0d8c1 160000 --- a/src/submodules/MessagePack-CSharp +++ b/src/submodules/MessagePack-CSharp @@ -1 +1 @@ -Subproject commit 9aeb12b9bdb024512ffe2e4bddfa2785dca6e39e +Subproject commit 365965f0d8c13c40ff8fde25882066b90f569c7a diff --git a/src/submodules/googletest b/src/submodules/googletest index 50b8600c63c5..d89aac5f0dd4 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit 50b8600c63c5487e901e2845a0f64d384a65f75d +Subproject commit d89aac5f0dd4021198d903d39de16f896726de21 From 83ca217d0f522f7a11672b065cbfc992e30dff8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:10:00 +0000 Subject: [PATCH 07/15] Modernize captured RenderTreeBuilder analyzer Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- docs/list-of-diagnostics.md | 3 +- .../DoNotUseLocalFunctionsInMarkupAnalyzer.cs | 167 ++++++++++++++ .../RenderTreeBuilderAnalyzer.cs | 117 +--------- .../DisallowNonLiteralSequenceNumbersTest.cs | 24 ++ .../DoNotUseLocalFunctionsInMarkupTest.cs | 211 +++++++++++++++--- 5 files changed, 369 insertions(+), 153 deletions(-) create mode 100644 src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 64ea810b2938..629a38041d68 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -2,7 +2,7 @@ ## Analyzer Warnings -### ASP (`ASP0000-ASP0038`) +### ASP (`ASP0000-ASP0039`) | Diagnostic ID | Description | | :---------------- | :---------- | @@ -44,6 +44,7 @@ | __`ASP0036`__ | Validatable property or its type on an endpoint parameter type is not accessible | | __`ASP0037`__ | \[ValidatableType] cannot be used in generated code | | __`ASP0038`__ | \[ValidatableType] should not be used without a call to 'AddValidation' | +| __`ASP0039`__ | Do not access a captured RenderTreeBuilder from a local function | ### API (`API1000-API1003`) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs new file mode 100644 index 000000000000..4a28ba713cf5 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -0,0 +1,167 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.AspNetCore.App.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; + +using WellKnownType = WellKnownTypeData.WellKnownType; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DoNotUseLocalFunctionsInMarkupAnalyzer : DiagnosticAnalyzer +{ + private const string BuildRenderTreeMethodName = "BuildRenderTree"; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(context => + { + var compilation = context.Compilation; + var wellKnownTypes = WellKnownTypes.GetOrCreate(compilation); + var componentBaseType = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.ComponentBase"); + var renderTreeBuilderType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Components_Rendering_RenderTreeBuilder); + var buildRenderTreeMethod = componentBaseType? + .GetMembers(BuildRenderTreeMethodName) + .OfType() + .FirstOrDefault(method => + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, renderTreeBuilderType)); + if (componentBaseType is null || renderTreeBuilderType is null || buildRenderTreeMethod is null) + { + return; + } + + context.RegisterSymbolStartAction(context => + { + var type = (INamedTypeSymbol)context.Symbol; + if (!InheritsFromComponentBase(type, componentBaseType)) + { + return; + } + + context.RegisterOperationBlockStartAction(context => + { + if (context.OwningSymbol is not IMethodSymbol method || + !Overrides(method, buildRenderTreeMethod)) + { + return; + } + + context.RegisterOperationAction(context => + { + var localFunction = (ILocalFunctionOperation)context.Operation; + if (localFunction.Symbol.IsStatic || + localFunction.Body is null || + !ContainsCapturedRenderTreeBuilderCall( + localFunction.Body, + ImmutableArray.Create(localFunction.Symbol), + renderTreeBuilderType)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup, + localFunction.Symbol.Locations.First(), + localFunction.Symbol.Name)); + }, OperationKind.LocalFunction); + }); + }, SymbolKind.NamedType); + }); + } + + private static bool InheritsFromComponentBase(INamedTypeSymbol type, INamedTypeSymbol componentBaseType) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, componentBaseType)) + { + return true; + } + } + + return false; + } + + private static bool Overrides(IMethodSymbol method, IMethodSymbol overriddenMethod) + { + for (var current = method; current is not null; current = current.OverriddenMethod) + { + if (SymbolEqualityComparer.Default.Equals(current, overriddenMethod)) + { + return true; + } + } + + return false; + } + + private static bool ContainsCapturedRenderTreeBuilderCall( + IOperation operation, + ImmutableArray localScopes, + INamedTypeSymbol renderTreeBuilderType) + { + if (operation is ILocalFunctionOperation) + { + return false; + } + + if (operation is IAnonymousFunctionOperation anonymousFunction) + { + return ContainsCapturedRenderTreeBuilderCall( + anonymousFunction.Body, + localScopes.Add(anonymousFunction.Symbol), + renderTreeBuilderType); + } + + if (operation is IInvocationOperation invocation && + SymbolEqualityComparer.Default.Equals(invocation.TargetMethod.ContainingType, renderTreeBuilderType) && + IsCaptured(invocation.Instance, localScopes)) + { + return true; + } + + foreach (var child in operation.ChildOperations) + { + if (ContainsCapturedRenderTreeBuilderCall(child, localScopes, renderTreeBuilderType)) + { + return true; + } + } + + return false; + } + + private static bool IsCaptured(IOperation? operation, ImmutableArray localScopes) + => operation switch + { + IConversionOperation conversion => IsCaptured(conversion.Operand, localScopes), + IFieldReferenceOperation => true, + ILocalReferenceOperation local => !IsDeclaredInLocalScope(local.Local, localScopes), + IParameterReferenceOperation parameter => !IsDeclaredInLocalScope(parameter.Parameter, localScopes), + _ => false, + }; + + private static bool IsDeclaredInLocalScope(ISymbol symbol, ImmutableArray localScopes) + { + foreach (var localScope in localScopes) + { + if (SymbolEqualityComparer.Default.Equals(symbol.ContainingSymbol, localScope)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs index ed0268eebde8..76c41aa8f066 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/RenderTreeBuilderAnalyzer.cs @@ -2,11 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; -using System.Linq; using Microsoft.AspNetCore.App.Analyzers.Infrastructure; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; @@ -18,9 +16,7 @@ namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; public partial class RenderTreeBuilderAnalyzer : DiagnosticAnalyzer { private const int SequenceParameterOrdinal = 0; - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( - DiagnosticDescriptors.DoNotUseNonLiteralSequenceNumbers, - DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.DoNotUseNonLiteralSequenceNumbers); public override void Initialize(AnalysisContext context) { @@ -57,46 +53,6 @@ public override void Initialize(AnalysisContext context) } }, OperationKind.Invocation); - - // Get ComponentBase type for scoping local function analysis - var componentBaseType = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.ComponentBase"); - - // Register syntax node action to detect local functions within BuildRenderTree methods - context.RegisterSyntaxNodeAction(context => - { - var methodDeclaration = (MethodDeclarationSyntax)context.Node; - - // Only analyze BuildRenderTree methods - if (methodDeclaration.Identifier.ValueText != "BuildRenderTree") - { - return; - } - - // Check if the containing type extends ComponentBase - var methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodDeclaration); - if (methodSymbol?.ContainingType == null || componentBaseType == null) - { - return; - } - - if (!InheritsFromComponentBase(methodSymbol.ContainingType, componentBaseType)) - { - return; - } - - // Now check for local functions within this BuildRenderTree method - var localFunctions = methodDeclaration.DescendantNodes().OfType(); - foreach (var localFunction in localFunctions) - { - if (ContainsRenderTreeBuilderCalls(wellKnownTypes, localFunction, context.SemanticModel)) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup, - localFunction.Identifier.GetLocation(), - localFunction.Identifier.ValueText)); - } - } - }, SyntaxKind.MethodDeclaration); }); } @@ -104,75 +60,4 @@ private static bool IsRenderTreeBuilderMethodWithSequenceParameter(WellKnownType => SymbolEqualityComparer.Default.Equals(wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Components_Rendering_RenderTreeBuilder), targetMethod.ContainingType) && targetMethod.Parameters.Length > SequenceParameterOrdinal && targetMethod.Parameters[SequenceParameterOrdinal].Name == "sequence"; - - private static bool InheritsFromComponentBase(INamedTypeSymbol type, INamedTypeSymbol componentBaseType) - { - var current = type.BaseType; - while (current != null) - { - if (SymbolEqualityComparer.Default.Equals(current, componentBaseType)) - { - return true; - } - current = current.BaseType; - } - return false; - } - - private static bool ContainsRenderTreeBuilderCalls(WellKnownTypes wellKnownTypes, LocalFunctionStatementSyntax localFunction, SemanticModel semanticModel) - { - var renderTreeBuilderType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Components_Rendering_RenderTreeBuilder); - if (renderTreeBuilderType is null) - { - return false; - } - - // Static local functions cannot capture from enclosing scope, so they're safe - if (localFunction.Modifiers.Any(SyntaxKind.StaticKeyword)) - { - return false; - } - - // Walk through all invocation expressions in the local function - var invocations = localFunction.DescendantNodes().OfType(); - - foreach (var invocation in invocations) - { - var symbolInfo = semanticModel.GetSymbolInfo(invocation); - if (symbolInfo.Symbol is IMethodSymbol method && - SymbolEqualityComparer.Default.Equals(renderTreeBuilderType, method.ContainingType)) - { - // Check if this is a call on a captured variable (not a parameter) - if (IsCallOnCapturedRenderTreeBuilder(invocation, localFunction, semanticModel, renderTreeBuilderType)) - { - return true; - } - } - } - - return false; - } - - private static bool IsCallOnCapturedRenderTreeBuilder(InvocationExpressionSyntax invocation, LocalFunctionStatementSyntax localFunction, SemanticModel semanticModel, INamedTypeSymbol _) - { - // Get the expression that the method is being called on - var memberAccess = invocation.Expression as MemberAccessExpressionSyntax; - if (memberAccess is null) - { - return false; - } - - var targetSymbol = semanticModel.GetSymbolInfo(memberAccess.Expression).Symbol; - - // If it's a parameter of the local function, it's not captured - if (targetSymbol is IParameterSymbol parameter) - { - // Check if this parameter belongs to our local function - var localFunctionSymbol = semanticModel.GetDeclaredSymbol(localFunction); - return localFunctionSymbol is not null && !localFunctionSymbol.Parameters.Contains(parameter, SymbolEqualityComparer.Default); - } - - // If it's a local variable or field, it could be captured - return targetSymbol is IFieldSymbol or ILocalSymbol; - } } diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs index e77605431efa..d7d077662054 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs @@ -89,4 +89,28 @@ public async Task RenderTreeBuilderInvocationWithInvocationArgument_ProducesDiag AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, diagnostic.Location); Assert.StartsWith("'ComputeSequenceNumber(0)' should not be used as a sequence number.", diagnostic.GetMessage(CultureInfo.InvariantCulture)); } + + [Fact] + public async Task RenderTreeBuilderInvocationInGeneratedCode_Works() + { + var source = @" +using System.CodeDom.Compiler; +using Microsoft.AspNetCore.Components.Rendering; + +_ = new TestComponent(); + +[GeneratedCode(""Razor"", ""1.0"")] +public class TestComponent +{ + public void BuildRenderTree(RenderTreeBuilder builder, int sequence) + { + builder.OpenElement(sequence, ""div""); + builder.CloseElement(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } } diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 7b417a5d3301..2e4acc1b2130 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -9,12 +9,11 @@ namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; public class DoNotUseLocalFunctionsInMarkupTest { - private TestDiagnosticAnalyzerRunner Runner { get; } = new(new RenderTreeBuilderAnalyzer()); + private TestDiagnosticAnalyzerRunner Runner { get; } = new(new DoNotUseLocalFunctionsInMarkupAnalyzer()); [Fact] public async Task LocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() { - // Arrange var source = TestSource.Read(@" using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -33,12 +32,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } "); - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); - // Assert - var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); - Assert.NotNull(analyzerDiagnostic); + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + Assert.Equal("ASP0039", analyzerDiagnostic.Id); AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); } @@ -46,7 +43,6 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) [Fact] public async Task LocalFunctionWithMultipleRenderTreeBuilderCalls_ProducesDiagnostic() { - // Arrange var source = TestSource.Read(@" using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -66,12 +62,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } "); - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); - // Assert - var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); - Assert.NotNull(analyzerDiagnostic); + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); } @@ -79,7 +72,6 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) [Fact] public async Task LocalFunctionWithoutRenderTreeBuilderCall_NoDiagnostic() { - // Arrange var source = @" void LocalFunction() { @@ -89,10 +81,8 @@ void LocalFunction() LocalFunction(); "; - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source); - // Assert var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); Assert.Empty(analyzerDiagnostics); } @@ -100,7 +90,6 @@ void LocalFunction() [Fact] public async Task LocalFunctionWithParameterRenderTreeBuilder_NoDiagnostic() { - // Arrange var source = @" using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -119,18 +108,126 @@ void LocalFunction(RenderTreeBuilder builderParam) } } "; - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source); - // Assert var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); Assert.Empty(analyzerDiagnostics); } + [Fact] + public async Task LocalFunctionWithLocalRenderTreeBuilder_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void LocalFunction() + { + var localBuilder = new RenderTreeBuilder(); + localBuilder.OpenElement(0, ""div""); + localBuilder.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithCapturedLocalRenderTreeBuilder_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var capturedBuilder = builder; + + void /*MM*/LocalFunction() + { + capturedBuilder.OpenElement(0, ""div""); + capturedBuilder.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionWithNestedLambdaBuilderParameter_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + RenderFragment LocalFunction() => childBuilder => + { + childBuilder.OpenElement(0, ""div""); + childBuilder.CloseElement(); + }; + + builder.AddContent(0, LocalFunction()); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithNestedLambdaCapturedRenderTreeBuilder_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + RenderFragment /*MM*/LocalFunction() => childBuilder => + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + }; + + builder.AddContent(0, LocalFunction()); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + [Fact] public async Task NestedLocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() { - // Arrange var source = TestSource.Read(@" using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -154,13 +251,10 @@ void OuterFunction() } } "); - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); - // Assert var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup).ToList(); - var innerFunctionDiagnostic = analyzerDiagnostics.FirstOrDefault(d => d.GetMessage(CultureInfo.InvariantCulture).Contains("InnerFunction")); - Assert.NotNull(innerFunctionDiagnostic); + var innerFunctionDiagnostic = Assert.Single(analyzerDiagnostics); AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, innerFunctionDiagnostic.Location); Assert.StartsWith("Local function 'InnerFunction' accesses RenderTreeBuilder from parent scope", innerFunctionDiagnostic.GetMessage(CultureInfo.InvariantCulture)); } @@ -168,7 +262,6 @@ void OuterFunction() [Fact] public async Task StaticLocalFunction_NoDiagnostic() { - // Arrange var source = @" using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -187,10 +280,8 @@ static void LocalFunction(RenderTreeBuilder builderParam) } } "; - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source); - // Assert var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); Assert.Empty(analyzerDiagnostics); } @@ -198,7 +289,6 @@ static void LocalFunction(RenderTreeBuilder builderParam) [Fact] public async Task LocalFunctionWithMethodInvocation_ProducesDiagnostic() { - // Arrange var source = TestSource.Read(@" using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -216,12 +306,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } "); - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); - // Assert - var analyzerDiagnostic = diagnostics.FirstOrDefault(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); - Assert.NotNull(analyzerDiagnostic); + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); } @@ -229,7 +316,6 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) [Fact] public async Task LocalFunctionOutsideComponentBase_NoDiagnostic() { - // Arrange var source = @" using Microsoft.AspNetCore.Components.Rendering; @@ -249,18 +335,73 @@ void LocalFunction() } } "; - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source); - // Assert var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); Assert.Empty(analyzerDiagnostics); } + [Fact] + public async Task LocalFunctionInBuildRenderTreeOverload_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + private void BuildRenderTree() + { + var builder = new RenderTreeBuilder(); + + void LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionInGeneratedBuildRenderTree_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using System.CodeDom.Compiler; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +[GeneratedCode(""Razor"", ""1.0"")] +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + [Fact] public async Task LocalFunctionInNonBuildRenderTreeMethod_NoDiagnostic() { - // Arrange var source = @" using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -281,10 +422,8 @@ void LocalFunction() } } "; - // Act var diagnostics = await Runner.GetDiagnosticsAsync(source); - // Assert var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); Assert.Empty(analyzerDiagnostics); } From d774c508ab6166ef5304165aa483e6ec01b83dd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:16:31 +0000 Subject: [PATCH 08/15] Remove analyzer test trailing whitespace Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupTest.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 2e4acc1b2130..2872ac2c48a2 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -27,7 +27,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenElement(0, ""div""); builder.CloseElement(); } - + LocalFunction(); } } @@ -57,7 +57,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddContent(1, ""text""); builder.CloseElement(); } - + LocalFunction(); } } @@ -103,7 +103,7 @@ void LocalFunction(RenderTreeBuilder builderParam) builderParam.OpenElement(0, ""div""); builderParam.CloseElement(); } - + LocalFunction(builder); } } @@ -243,10 +243,10 @@ void OuterFunction() builder.OpenElement(0, ""div""); builder.CloseElement(); } - + InnerFunction(); } - + OuterFunction(); } } @@ -275,7 +275,7 @@ static void LocalFunction(RenderTreeBuilder builderParam) builderParam.OpenElement(0, ""div""); builderParam.CloseElement(); } - + LocalFunction(builder); } } @@ -301,7 +301,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) { builder.AddMarkupContent(0, ""
Hello
""); } - + LocalFunction(); } } @@ -324,13 +324,13 @@ public class NotAComponent public void SomeMethod() { var builder = new RenderTreeBuilder(); - + void LocalFunction() { builder.OpenElement(0, ""div""); builder.CloseElement(); } - + LocalFunction(); } } @@ -411,13 +411,13 @@ public class TestComponent : ComponentBase private void SomeOtherMethod() { var builder = new RenderTreeBuilder(); - + void LocalFunction() { builder.OpenElement(0, ""div""); builder.CloseElement(); } - + LocalFunction(); } } From a42cad48724afde5751d31a3d56a38fbc93f4586 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:15:45 +0000 Subject: [PATCH 09/15] Track owning RenderTreeBuilder provenance Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupAnalyzer.cs | 228 ++++++++++++++---- .../DoNotUseLocalFunctionsInMarkupTest.cs | 142 ++++++++++- ...osoft.AspNetCore.App.Analyzers.Test.csproj | 1 + 3 files changed, 320 insertions(+), 51 deletions(-) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs index 4a28ba713cf5..9ba48f310fcf 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.AspNetCore.App.Analyzers.Infrastructure; @@ -49,7 +50,7 @@ public override void Initialize(AnalysisContext context) return; } - context.RegisterOperationBlockStartAction(context => + context.RegisterOperationBlockAction(context => { if (context.OwningSymbol is not IMethodSymbol method || !Overrides(method, buildRenderTreeMethod)) @@ -57,24 +58,25 @@ public override void Initialize(AnalysisContext context) return; } - context.RegisterOperationAction(context => + var localFunctions = new Dictionary(SymbolEqualityComparer.Default); + foreach (var operationBlock in context.OperationBlocks) { - var localFunction = (ILocalFunctionOperation)context.Operation; - if (localFunction.Symbol.IsStatic || - localFunction.Body is null || - !ContainsCapturedRenderTreeBuilderCall( - localFunction.Body, - ImmutableArray.Create(localFunction.Symbol), - renderTreeBuilderType)) - { - return; - } + CollectLocalFunctions(operationBlock, localFunctions); + } + var walker = new OwningBuilderWalker(method.Parameters[0], renderTreeBuilderType, localFunctions); + foreach (var operationBlock in context.OperationBlocks) + { + walker.Visit(operationBlock); + } + + foreach (var localFunction in walker.LocalFunctionsUsingOwningBuilder) + { context.ReportDiagnostic(Diagnostic.Create( DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup, - localFunction.Symbol.Locations.First(), - localFunction.Symbol.Name)); - }, OperationKind.LocalFunction); + localFunction.Locations.First(), + localFunction.Name)); + } }); }, SymbolKind.NamedType); }); @@ -106,62 +108,190 @@ private static bool Overrides(IMethodSymbol method, IMethodSymbol overriddenMeth return false; } - private static bool ContainsCapturedRenderTreeBuilderCall( + private static void CollectLocalFunctions( IOperation operation, - ImmutableArray localScopes, - INamedTypeSymbol renderTreeBuilderType) + Dictionary localFunctions) { - if (operation is ILocalFunctionOperation) + if (operation is ILocalFunctionOperation localFunction) { - return false; + localFunctions.Add(localFunction.Symbol, localFunction); } - if (operation is IAnonymousFunctionOperation anonymousFunction) + foreach (var child in operation.ChildOperations) { - return ContainsCapturedRenderTreeBuilderCall( - anonymousFunction.Body, - localScopes.Add(anonymousFunction.Symbol), - renderTreeBuilderType); + CollectLocalFunctions(child, localFunctions); } + } + + private sealed class OwningBuilderWalker : OperationWalker + { + private readonly INamedTypeSymbol _renderTreeBuilderType; + private readonly Dictionary _localFunctions; + private readonly HashSet _activeLocalFunctions = new(SymbolEqualityComparer.Default); + private readonly Dictionary _provenance = new(SymbolEqualityComparer.Default); + private IMethodSymbol? _currentLocalFunction; - if (operation is IInvocationOperation invocation && - SymbolEqualityComparer.Default.Equals(invocation.TargetMethod.ContainingType, renderTreeBuilderType) && - IsCaptured(invocation.Instance, localScopes)) + public OwningBuilderWalker( + IParameterSymbol owningBuilder, + INamedTypeSymbol renderTreeBuilderType, + Dictionary localFunctions) { - return true; + _renderTreeBuilderType = renderTreeBuilderType; + _localFunctions = localFunctions; + _provenance.Add(owningBuilder, true); } - foreach (var child in operation.ChildOperations) + public HashSet LocalFunctionsUsingOwningBuilder { get; } = new(SymbolEqualityComparer.Default); + + public override void VisitLocalFunction(ILocalFunctionOperation operation) + { + } + + public override void VisitAnonymousFunction(IAnonymousFunctionOperation operation) { - if (ContainsCapturedRenderTreeBuilderCall(child, localScopes, renderTreeBuilderType)) + var previousProvenance = CloneProvenance(); + foreach (var parameter in operation.Symbol.Parameters) { - return true; + _provenance[parameter] = false; } + + Visit(operation.Body); + RestoreProvenance(previousProvenance); } - return false; - } + public override void VisitVariableDeclarator(IVariableDeclaratorOperation operation) + { + if (operation.Initializer is { } initializer) + { + Visit(initializer.Value); + _provenance[operation.Symbol] = HasOwningBuilderProvenance(initializer.Value); + } + else + { + _provenance[operation.Symbol] = false; + } + } - private static bool IsCaptured(IOperation? operation, ImmutableArray localScopes) - => operation switch + public override void VisitSimpleAssignment(ISimpleAssignmentOperation operation) { - IConversionOperation conversion => IsCaptured(conversion.Operand, localScopes), - IFieldReferenceOperation => true, - ILocalReferenceOperation local => !IsDeclaredInLocalScope(local.Local, localScopes), - IParameterReferenceOperation parameter => !IsDeclaredInLocalScope(parameter.Parameter, localScopes), - _ => false, - }; - - private static bool IsDeclaredInLocalScope(ISymbol symbol, ImmutableArray localScopes) - { - foreach (var localScope in localScopes) + Visit(operation.Value); + if (GetReferencedSymbol(operation.Target) is { } target) + { + _provenance[target] = HasOwningBuilderProvenance(operation.Value); + } + } + + public override void VisitConditional(IConditionalOperation operation) { - if (SymbolEqualityComparer.Default.Equals(symbol.ContainingSymbol, localScope)) + Visit(operation.Condition); + var initialProvenance = CloneProvenance(); + + Visit(operation.WhenTrue); + var whenTrueProvenance = CloneProvenance(); + + RestoreProvenance(initialProvenance); + if (operation.WhenFalse is { } whenFalse) { - return true; + Visit(whenFalse); } + + MergeProvenance(whenTrueProvenance); } - return false; + public override void VisitInvocation(IInvocationOperation operation) + { + Visit(operation.Instance); + foreach (var argument in operation.Arguments) + { + Visit(argument.Value); + } + + if (_currentLocalFunction is not null && + SymbolEqualityComparer.Default.Equals(operation.TargetMethod.ContainingType, _renderTreeBuilderType) && + HasOwningBuilderProvenance(operation.Instance)) + { + LocalFunctionsUsingOwningBuilder.Add(_currentLocalFunction); + } + + if (_localFunctions.TryGetValue(operation.TargetMethod, out var localFunction)) + { + VisitLocalFunctionInvocation(localFunction); + } + } + + private void VisitLocalFunctionInvocation(ILocalFunctionOperation localFunction) + { + if (localFunction.Symbol.IsStatic || + localFunction.Body is null || + !_activeLocalFunctions.Add(localFunction.Symbol)) + { + return; + } + + var previousLocalFunction = _currentLocalFunction; + _currentLocalFunction = localFunction.Symbol; + foreach (var parameter in localFunction.Symbol.Parameters) + { + _provenance[parameter] = false; + } + + Visit(localFunction.Body); + + _currentLocalFunction = previousLocalFunction; + _activeLocalFunctions.Remove(localFunction.Symbol); + } + + private bool HasOwningBuilderProvenance(IOperation? operation) + => operation switch + { + IConversionOperation conversion => HasOwningBuilderProvenance(conversion.Operand), + IParenthesizedOperation parenthesized => HasOwningBuilderProvenance(parenthesized.Operand), + ILocalReferenceOperation local => GetProvenance(local.Local), + IParameterReferenceOperation parameter => GetProvenance(parameter.Parameter), + IFieldReferenceOperation field => GetProvenance(field.Field), + IConditionalOperation conditional => HasOwningBuilderProvenance(conditional.WhenTrue) || + HasOwningBuilderProvenance(conditional.WhenFalse), + ICoalesceOperation coalesce => HasOwningBuilderProvenance(coalesce.Value) || + HasOwningBuilderProvenance(coalesce.WhenNull), + _ => false, + }; + + private bool GetProvenance(ISymbol symbol) + => _provenance.TryGetValue(symbol, out var hasOwningBuilderProvenance) && + hasOwningBuilderProvenance; + + private static ISymbol? GetReferencedSymbol(IOperation operation) + => operation switch + { + IConversionOperation conversion => GetReferencedSymbol(conversion.Operand), + IParenthesizedOperation parenthesized => GetReferencedSymbol(parenthesized.Operand), + ILocalReferenceOperation local => local.Local, + IParameterReferenceOperation parameter => parameter.Parameter, + IFieldReferenceOperation field => field.Field, + _ => null, + }; + + private Dictionary CloneProvenance() + => new(_provenance, SymbolEqualityComparer.Default); + + private void RestoreProvenance(Dictionary provenance) + { + _provenance.Clear(); + foreach (var item in provenance) + { + _provenance.Add(item.Key, item.Value); + } + } + + private void MergeProvenance(Dictionary provenance) + { + foreach (var item in provenance) + { + if (item.Value) + { + _provenance[item.Key] = true; + } + } + } } } diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 2872ac2c48a2..3888d27db302 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Razor.Language; namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; @@ -143,7 +144,7 @@ void LocalFunction() } [Fact] - public async Task LocalFunctionWithCapturedLocalRenderTreeBuilder_ProducesDiagnostic() + public async Task LocalFunctionWithOwningBuilderAlias_ProducesDiagnostic() { var source = TestSource.Read(@" using Microsoft.AspNetCore.Components; @@ -171,6 +172,101 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); } + [Fact] + public async Task LocalFunctionWithReassignedOwningBuilderAlias_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + alias = childBuilder; + + void LocalFunction() + { + alias.OpenElement(0, ""div""); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithAliasReassignedToOwningBuilder_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + alias = builder; + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, ""div""); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task NestedLocalFunctionWithFreshCapturedBuilder_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void Outer() + { + var scratch = new RenderTreeBuilder(); + + void Inner() + { + scratch.OpenElement(0, ""div""); + scratch.CloseElement(); + } + + Inner(); + } + + Outer(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + [Fact] public async Task LocalFunctionWithNestedLambdaBuilderParameter_NoDiagnostic() { @@ -399,6 +495,48 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); } + [Fact] + public async Task LocalFunctionInRazorGeneratedBuildRenderTree_ProducesDiagnostic() + { + var razorSource = """ +@using Microsoft.AspNetCore.Components +@inherits ComponentBase +@{ + void RenderTree(int depth, int maxDepth) + { + if (depth >= maxDepth) + { + return; + } + + + @{ RenderTree(depth + 1, maxDepth); } + + } + + RenderTree(0, 2); +} +"""; + var projectEngine = RazorProjectEngine.Create( + RazorConfiguration.Default, + RazorProjectFileSystem.Create("/"), + builder => builder.SetRootNamespace("Test")); + var codeDocument = projectEngine.Process( + RazorSourceDocument.Create(razorSource, "/IssueSample.razor"), + FileKinds.Component, + Array.Empty(), + Array.Empty()); + var generatedCode = codeDocument.GetCSharpDocument().GeneratedCode; + + Assert.Contains("void RenderTree(int depth, int maxDepth)", generatedCode); + Assert.Contains("__builder.OpenElement", generatedCode); + + var diagnostics = await Runner.GetDiagnosticsAsync(generatedCode); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + Assert.Equal("/IssueSample.razor", analyzerDiagnostic.Location.GetMappedLineSpan().Path); + } + [Fact] public async Task LocalFunctionInNonBuildRenderTreeMethod_NoDiagnostic() { @@ -427,4 +565,4 @@ void LocalFunction() var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); Assert.Empty(analyzerDiagnostics); } -} \ No newline at end of file +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj index ab3270d51175..b4e51b1d9e91 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj +++ b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj @@ -51,6 +51,7 @@ + From b654ce5ba22892328dc83e5dbcbc0bd0513c6e43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:19:25 +0000 Subject: [PATCH 10/15] Cover indirect ASP0039 provenance flows Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupAnalyzer.cs | 30 ++++++ .../DoNotUseLocalFunctionsInMarkupTest.cs | 97 +++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs index 9ba48f310fcf..1c693b1fe93a 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -198,6 +198,26 @@ public override void VisitConditional(IConditionalOperation operation) MergeProvenance(whenTrueProvenance); } + public override void VisitSwitch(ISwitchOperation operation) + { + Visit(operation.Value); + var initialProvenance = CloneProvenance(); + var mergedProvenance = CloneProvenance(); + + foreach (var @case in operation.Cases) + { + RestoreProvenance(initialProvenance); + Visit(@case); + var caseProvenance = CloneProvenance(); + + RestoreProvenance(mergedProvenance); + MergeProvenance(caseProvenance); + mergedProvenance = CloneProvenance(); + } + + RestoreProvenance(mergedProvenance); + } + public override void VisitInvocation(IInvocationOperation operation) { Visit(operation.Instance); @@ -219,6 +239,15 @@ public override void VisitInvocation(IInvocationOperation operation) } } + public override void VisitMethodReference(IMethodReferenceOperation operation) + { + Visit(operation.Instance); + if (_localFunctions.TryGetValue(operation.Method, out var localFunction)) + { + VisitLocalFunctionInvocation(localFunction); + } + } + private void VisitLocalFunctionInvocation(ILocalFunctionOperation localFunction) { if (localFunction.Symbol.IsStatic || @@ -253,6 +282,7 @@ private bool HasOwningBuilderProvenance(IOperation? operation) HasOwningBuilderProvenance(conditional.WhenFalse), ICoalesceOperation coalesce => HasOwningBuilderProvenance(coalesce.Value) || HasOwningBuilderProvenance(coalesce.WhenNull), + ISimpleAssignmentOperation assignment => HasOwningBuilderProvenance(assignment.Value), _ => false, }; diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 3888d27db302..df6ce63addca 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -233,6 +233,103 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); } + [Fact] + public async Task LocalFunctionWithChainedOwningBuilderAlias_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + var another = alias = builder; + + void /*MM*/LocalFunction() + { + another.OpenElement(0, ""div""); + another.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionUsedAsRenderFragmentMethodGroup_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction(RenderTreeBuilder childBuilder) + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + RenderFragment fragment = LocalFunction; + builder.AddContent(0, fragment); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionInIndependentSwitchCaseWithOwningBuilderAlias_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + var value = 2; + + switch (value) + { + case 1: + alias = childBuilder; + break; + case 2: + void /*MM*/LocalFunction() + { + alias.OpenElement(0, ""div""); + alias.CloseElement(); + } + + LocalFunction(); + break; + } + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + [Fact] public async Task NestedLocalFunctionWithFreshCapturedBuilder_NoDiagnostic() { From c72149f27d7048a5c2c76995ba6d9fb5fa1a0fda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:17 +0000 Subject: [PATCH 11/15] Fix ASP0039 loop provenance and Razor coverage Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupAnalyzer.cs | 105 +++++++ .../DoNotUseLocalFunctionsInMarkupTest.cs | 291 ++++++++++++++++-- ...osoft.AspNetCore.App.Analyzers.Test.csproj | 9 +- .../DoNotUseLocalFunctionsInMarkup.csproj | 14 + .../FluentTreeItem.razor | 11 + .../IssueSample.razor | 15 + 6 files changed, 412 insertions(+), 33 deletions(-) create mode 100644 src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/DoNotUseLocalFunctionsInMarkup.csproj create mode 100644 src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/FluentTreeItem.razor create mode 100644 src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/IssueSample.razor diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs index 1c693b1fe93a..8315dc702f33 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -218,6 +219,70 @@ public override void VisitSwitch(ISwitchOperation operation) RestoreProvenance(mergedProvenance); } + public override void VisitWhileLoop(IWhileLoopOperation operation) + { + if (operation.ConditionIsTop) + { + VisitLoop( + () => + { + Visit(operation.Condition); + Visit(operation.Body); + }, + () => Visit(operation.Condition), + executesAtLeastOnce: false); + } + else + { + VisitLoop( + () => + { + Visit(operation.Body); + Visit(operation.Condition); + }, + visitExit: null, + executesAtLeastOnce: true); + } + } + + public override void VisitForLoop(IForLoopOperation operation) + { + foreach (var before in operation.Before) + { + Visit(before); + } + + VisitLoop( + () => + { + Visit(operation.Condition); + Visit(operation.Body); + foreach (var atLoopBottom in operation.AtLoopBottom) + { + Visit(atLoopBottom); + } + }, + () => Visit(operation.Condition), + executesAtLeastOnce: false); + } + + public override void VisitForEachLoop(IForEachLoopOperation operation) + { + Visit(operation.Collection); + VisitLoop( + () => + { + Visit(operation.LoopControlVariable); + Visit(operation.Body); + foreach (var nextVariable in operation.NextVariables) + { + Visit(nextVariable); + } + }, + visitExit: null, + executesAtLeastOnce: false); + } + public override void VisitInvocation(IInvocationOperation operation) { Visit(operation.Instance); @@ -270,6 +335,36 @@ localFunction.Body is null || _activeLocalFunctions.Remove(localFunction.Symbol); } + private void VisitLoop(Action visitIteration, Action? visitExit, bool executesAtLeastOnce) + { + var loopStates = CloneProvenance(); + if (executesAtLeastOnce) + { + visitIteration(); + loopStates = CloneProvenance(); + } + + while (true) + { + RestoreProvenance(loopStates); + visitIteration(); + var iterationEnd = CloneProvenance(); + + RestoreProvenance(loopStates); + MergeProvenance(iterationEnd); + var mergedStates = CloneProvenance(); + if (HasSameProvenance(loopStates, mergedStates)) + { + break; + } + + loopStates = mergedStates; + } + + RestoreProvenance(loopStates); + visitExit?.Invoke(); + } + private bool HasOwningBuilderProvenance(IOperation? operation) => operation switch { @@ -323,5 +418,15 @@ private void MergeProvenance(Dictionary provenance) } } } + + private static bool HasSameProvenance( + Dictionary left, + Dictionary right) + => left.All(item => !item.Value || GetProvenance(right, item.Key)) && + right.All(item => !item.Value || GetProvenance(left, item.Key)); + + private static bool GetProvenance(Dictionary provenance, ISymbol symbol) + => provenance.TryGetValue(symbol, out var hasOwningBuilderProvenance) && + hasOwningBuilderProvenance; } } diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index df6ce63addca..9733bb017f55 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.Linq; using Microsoft.AspNetCore.Analyzer.Testing; -using Microsoft.AspNetCore.Razor.Language; namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; @@ -330,6 +329,260 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); } + [Theory] + [InlineData(""" +while (GetCondition()) +{ + alias = childBuilder; +} +""")] + [InlineData(""" +for (var i = 0; i < GetCount(); i++) +{ + alias = childBuilder; +} +""")] + [InlineData(""" +foreach (var item in GetItems()) +{ + alias = childBuilder; +} +""")] + public async Task LocalFunctionWithOwningBuilderReassignedInZeroOrMoreLoop_ProducesDiagnostic(string loop) + { + var source = TestSource.Read($$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + {{loop}} + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; + private int GetCount() => 0; + private int[] GetItems() => []; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Theory] + [InlineData(""" +while (GetCondition()) +{ + alias = builder; +} +""")] + [InlineData(""" +for (var i = 0; i < GetCount(); i++) +{ + alias = builder; +} +""")] + [InlineData(""" +foreach (var item in GetItems()) +{ + alias = builder; +} +""")] + public async Task LocalFunctionWithFreshBuilderReassignedToOwningInZeroOrMoreLoop_ProducesDiagnostic(string loop) + { + var source = TestSource.Read($$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + {{loop}} + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; + private int GetCount() => 0; + private int[] GetItems() => []; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionWithOwningBuilderReassignedInDoLoop_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = builder; + do + { + alias = new RenderTreeBuilder(); + } + while (GetCondition()); + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithFreshBuilderReassignedToOwningInDoLoop_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + do + { + alias = builder; + } + while (GetCondition()); + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionWithDefiniteReassignmentFromWhileCondition_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = builder; + + bool ShouldContinue() + { + alias = new RenderTreeBuilder(); + return false; + } + + while (ShouldContinue()) + { + } + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithOwningBuilderAliasPropagatedAcrossLoopIterations_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var first = new RenderTreeBuilder(); + var second = new RenderTreeBuilder(); + + while (GetCondition()) + { + first = second; + second = builder; + } + + void /*MM*/LocalFunction() + { + first.OpenElement(0, "div"); + first.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + [Fact] public async Task NestedLocalFunctionWithFreshCapturedBuilder_NoDiagnostic() { @@ -595,43 +848,17 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) [Fact] public async Task LocalFunctionInRazorGeneratedBuildRenderTree_ProducesDiagnostic() { - var razorSource = """ -@using Microsoft.AspNetCore.Components -@inherits ComponentBase -@{ - void RenderTree(int depth, int maxDepth) - { - if (depth >= maxDepth) - { - return; - } - - - @{ RenderTree(depth + 1, maxDepth); } - - } - - RenderTree(0, 2); -} -"""; - var projectEngine = RazorProjectEngine.Create( - RazorConfiguration.Default, - RazorProjectFileSystem.Create("/"), - builder => builder.SetRootNamespace("Test")); - var codeDocument = projectEngine.Process( - RazorSourceDocument.Create(razorSource, "/IssueSample.razor"), - FileKinds.Component, - Array.Empty(), - Array.Empty()); - var generatedCode = codeDocument.GetCSharpDocument().GeneratedCode; + var generatedCode = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "IssueSample_razor.g.cs")); Assert.Contains("void RenderTree(int depth, int maxDepth)", generatedCode); - Assert.Contains("__builder.OpenElement", generatedCode); + Assert.Contains("__builder.OpenComponent", generatedCode); + Assert.Contains("\"ChildContent\"", generatedCode); + Assert.Contains("RenderTree(depth + 1, maxDepth)", generatedCode); var diagnostics = await Runner.GetDiagnosticsAsync(generatedCode); var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); - Assert.Equal("/IssueSample.razor", analyzerDiagnostic.Location.GetMappedLineSpan().Path); + Assert.Equal("IssueSample.razor", Path.GetFileName(analyzerDiagnostic.Location.GetMappedLineSpan().Path)); } [Fact] diff --git a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj index b4e51b1d9e91..426006209254 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj +++ b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj @@ -16,6 +16,7 @@ + @@ -51,7 +52,7 @@ - + @@ -59,6 +60,12 @@ + + + + diff --git a/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/DoNotUseLocalFunctionsInMarkup.csproj b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/DoNotUseLocalFunctionsInMarkup.csproj new file mode 100644 index 000000000000..6f0a7d26324e --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/DoNotUseLocalFunctionsInMarkup.csproj @@ -0,0 +1,14 @@ + + + + $(DefaultNetCoreTargetFramework) + true + $(IntermediateOutputPath)generated + enable + + + + + + + diff --git a/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/FluentTreeItem.razor b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/FluentTreeItem.razor new file mode 100644 index 000000000000..ab0a8a7a3cd2 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/FluentTreeItem.razor @@ -0,0 +1,11 @@ +@using Microsoft.AspNetCore.Components + +
@Text @ChildContent
+ +@code { + [Parameter] + public string? Text { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/IssueSample.razor b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/IssueSample.razor new file mode 100644 index 000000000000..33717918eefb --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/IssueSample.razor @@ -0,0 +1,15 @@ +@{ + void RenderTree(int depth, int maxDepth) + { + if (depth >= maxDepth) + { + return; + } + + + @{ RenderTree(depth + 1, maxDepth); } + + } + + RenderTree(0, 2); +} From c3b90da507772ef35b9e370c86d517458e99e304 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:37:55 +0000 Subject: [PATCH 12/15] Handle ASP0039 loop branch provenance Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupAnalyzer.cs | 290 ++++++++++++++++-- .../DoNotUseLocalFunctionsInMarkupTest.cs | 89 ++++++ 2 files changed, 353 insertions(+), 26 deletions(-) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs index 8315dc702f33..c8d8a98dc6c8 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -130,7 +130,9 @@ private sealed class OwningBuilderWalker : OperationWalker private readonly Dictionary _localFunctions; private readonly HashSet _activeLocalFunctions = new(SymbolEqualityComparer.Default); private readonly Dictionary _provenance = new(SymbolEqualityComparer.Default); + private readonly Stack _loopContexts = new(); private IMethodSymbol? _currentLocalFunction; + private bool _pathTerminated; public OwningBuilderWalker( IParameterSymbol owningBuilder, @@ -182,21 +184,82 @@ public override void VisitSimpleAssignment(ISimpleAssignmentOperation operation) } } + public override void VisitBlock(IBlockOperation operation) + { + foreach (var child in operation.Operations) + { + Visit(child); + if (_pathTerminated) + { + break; + } + } + } + + public override void VisitBranch(IBranchOperation operation) + { + var correspondingOperation = operation.GetCorrespondingOperation(); + foreach (var loopContext in _loopContexts) + { + if (!ReferenceEquals(correspondingOperation, loopContext.Operation)) + { + continue; + } + + switch (operation.BranchKind) + { + case BranchKind.Break: + loopContext.BreakStates.Add(CloneProvenance()); + _pathTerminated = true; + break; + case BranchKind.Continue: + loopContext.ContinueStates.Add(CloneProvenance()); + _pathTerminated = true; + break; + } + + return; + } + } + public override void VisitConditional(IConditionalOperation operation) { Visit(operation.Condition); var initialProvenance = CloneProvenance(); + _pathTerminated = false; Visit(operation.WhenTrue); var whenTrueProvenance = CloneProvenance(); + var whenTrueTerminated = _pathTerminated; RestoreProvenance(initialProvenance); + _pathTerminated = false; if (operation.WhenFalse is { } whenFalse) { Visit(whenFalse); } - MergeProvenance(whenTrueProvenance); + var whenFalseProvenance = CloneProvenance(); + var whenFalseTerminated = _pathTerminated; + if (whenTrueTerminated && whenFalseTerminated) + { + _pathTerminated = true; + } + else if (whenTrueTerminated) + { + RestoreProvenance(whenFalseProvenance); + _pathTerminated = false; + } + else + { + RestoreProvenance(whenTrueProvenance); + if (!whenFalseTerminated) + { + MergeProvenance(whenFalseProvenance); + } + + _pathTerminated = false; + } } public override void VisitSwitch(ISwitchOperation operation) @@ -208,7 +271,13 @@ public override void VisitSwitch(ISwitchOperation operation) foreach (var @case in operation.Cases) { RestoreProvenance(initialProvenance); + _pathTerminated = false; Visit(@case); + if (_pathTerminated) + { + continue; + } + var caseProvenance = CloneProvenance(); RestoreProvenance(mergedProvenance); @@ -217,6 +286,24 @@ public override void VisitSwitch(ISwitchOperation operation) } RestoreProvenance(mergedProvenance); + _pathTerminated = false; + } + + public override void VisitSwitchCase(ISwitchCaseOperation operation) + { + foreach (var clause in operation.Clauses) + { + Visit(clause); + } + + foreach (var child in operation.Body) + { + Visit(child); + if (_pathTerminated) + { + break; + } + } } public override void VisitWhileLoop(IWhileLoopOperation operation) @@ -224,22 +311,32 @@ public override void VisitWhileLoop(IWhileLoopOperation operation) if (operation.ConditionIsTop) { VisitLoop( + operation, () => { Visit(operation.Condition); - Visit(operation.Body); + if (!_pathTerminated) + { + Visit(operation.Body); + } }, + visitContinue: null, () => Visit(operation.Condition), executesAtLeastOnce: false); } else { VisitLoop( + operation, () => { Visit(operation.Body); - Visit(operation.Condition); + if (!_pathTerminated) + { + Visit(operation.Condition); + } }, + () => Visit(operation.Condition), visitExit: null, executesAtLeastOnce: true); } @@ -253,15 +350,21 @@ public override void VisitForLoop(IForLoopOperation operation) } VisitLoop( + operation, () => { Visit(operation.Condition); - Visit(operation.Body); - foreach (var atLoopBottom in operation.AtLoopBottom) + if (!_pathTerminated) + { + Visit(operation.Body); + } + + if (!_pathTerminated) { - Visit(atLoopBottom); + VisitForLoopBottom(operation); } }, + () => VisitForLoopBottom(operation), () => Visit(operation.Condition), executesAtLeastOnce: false); } @@ -270,15 +373,21 @@ public override void VisitForEachLoop(IForEachLoopOperation operation) { Visit(operation.Collection); VisitLoop( + operation, () => { Visit(operation.LoopControlVariable); - Visit(operation.Body); - foreach (var nextVariable in operation.NextVariables) + if (!_pathTerminated) { - Visit(nextVariable); + Visit(operation.Body); + } + + if (!_pathTerminated) + { + VisitForEachLoopBottom(operation); } }, + () => VisitForEachLoopBottom(operation), visitExit: null, executesAtLeastOnce: false); } @@ -335,34 +444,111 @@ localFunction.Body is null || _activeLocalFunctions.Remove(localFunction.Symbol); } - private void VisitLoop(Action visitIteration, Action? visitExit, bool executesAtLeastOnce) + private void VisitForLoopBottom(IForLoopOperation operation) + { + foreach (var atLoopBottom in operation.AtLoopBottom) + { + Visit(atLoopBottom); + } + } + + private void VisitForEachLoopBottom(IForEachLoopOperation operation) { - var loopStates = CloneProvenance(); - if (executesAtLeastOnce) + foreach (var nextVariable in operation.NextVariables) { - visitIteration(); - loopStates = CloneProvenance(); + Visit(nextVariable); } + } - while (true) + private void VisitLoop( + ILoopOperation operation, + Action visitIteration, + Action? visitContinue, + Action? visitExit, + bool executesAtLeastOnce) + { + var loopContext = new LoopContext(operation); + _loopContexts.Push(loopContext); + try { - RestoreProvenance(loopStates); - visitIteration(); - var iterationEnd = CloneProvenance(); + Dictionary? loopStates; + if (executesAtLeastOnce) + { + loopStates = VisitLoopIteration(loopContext, visitIteration, visitContinue); + } + else + { + loopStates = CloneProvenance(); + } - RestoreProvenance(loopStates); - MergeProvenance(iterationEnd); - var mergedStates = CloneProvenance(); - if (HasSameProvenance(loopStates, mergedStates)) + while (loopStates is not null) { - break; + RestoreProvenance(loopStates); + var iterationEnd = VisitLoopIteration(loopContext, visitIteration, visitContinue); + var mergedStates = MergeProvenance(loopStates, iterationEnd)!; + if (HasSameProvenance(loopStates, mergedStates)) + { + loopStates = mergedStates; + break; + } + + loopStates = mergedStates; + } + + Dictionary? exitStates = null; + if (loopStates is not null) + { + RestoreProvenance(loopStates); + _pathTerminated = false; + visitExit?.Invoke(); + if (!_pathTerminated) + { + exitStates = CloneProvenance(); + } } - loopStates = mergedStates; + exitStates = MergeProvenance(exitStates, MergeProvenance(loopContext.BreakStates)); + if (exitStates is not null) + { + RestoreProvenance(exitStates); + } + + _pathTerminated = exitStates is null; + } + finally + { + _loopContexts.Pop(); + } + } + + private Dictionary? VisitLoopIteration( + LoopContext loopContext, + Action visitIteration, + Action? visitContinue) + { + loopContext.ContinueStates.Clear(); + _pathTerminated = false; + visitIteration(); + + var iterationStates = new List>(); + if (!_pathTerminated) + { + iterationStates.Add(CloneProvenance()); + } + + foreach (var continueState in loopContext.ContinueStates) + { + RestoreProvenance(continueState); + _pathTerminated = false; + visitContinue?.Invoke(); + if (!_pathTerminated) + { + iterationStates.Add(CloneProvenance()); + } } - RestoreProvenance(loopStates); - visitExit?.Invoke(); + _pathTerminated = false; + return MergeProvenance(iterationStates); } private bool HasOwningBuilderProvenance(IOperation? operation) @@ -419,6 +605,44 @@ private void MergeProvenance(Dictionary provenance) } } + private static Dictionary? MergeProvenance( + Dictionary? left, + Dictionary? right) + { + if (left is null) + { + return right; + } + + if (right is null) + { + return left; + } + + var merged = new Dictionary(left, SymbolEqualityComparer.Default); + foreach (var item in right) + { + if (item.Value) + { + merged[item.Key] = true; + } + } + + return merged; + } + + private static Dictionary? MergeProvenance( + IEnumerable> states) + { + Dictionary? merged = null; + foreach (var state in states) + { + merged = MergeProvenance(merged, state); + } + + return merged; + } + private static bool HasSameProvenance( Dictionary left, Dictionary right) @@ -428,5 +652,19 @@ private static bool HasSameProvenance( private static bool GetProvenance(Dictionary provenance, ISymbol symbol) => provenance.TryGetValue(symbol, out var hasOwningBuilderProvenance) && hasOwningBuilderProvenance; + + private sealed class LoopContext + { + public LoopContext(ILoopOperation operation) + { + Operation = operation; + } + + public ILoopOperation Operation { get; } + + public List> BreakStates { get; } = []; + + public List> ContinueStates { get; } = []; + } } } diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 9733bb017f55..79b4f72c5783 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -583,6 +583,95 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); } + [Theory] + [InlineData("break;")] + [InlineData("continue;")] + public async Task LocalFunctionWithOwningBuilderOnLoopBranch_ProducesDiagnostic(string branch) + { + var source = TestSource.Read($$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = childBuilder; + while (GetCondition()) + { + if (GetCondition()) + { + alias = builder; + {{branch}} + } + + alias = childBuilder; + } + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Theory] + [InlineData("break;")] + [InlineData("continue;")] + public async Task LocalFunctionWithFreshBuilderOnDoLoopBranch_NoDiagnostic(string branch) + { + var source = $$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + do + { + if (GetCondition()) + { + alias = childBuilder; + {{branch}} + } + + alias = childBuilder; + } + while (GetCondition()); + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + [Fact] public async Task NestedLocalFunctionWithFreshCapturedBuilder_NoDiagnostic() { From c4561303def3c2ffb361ce9b4b6336761207b24f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:54:59 +0000 Subject: [PATCH 13/15] Preserve terminating switch loop paths Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupAnalyzer.cs | 20 +++++--- .../DoNotUseLocalFunctionsInMarkupTest.cs | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs index c8d8a98dc6c8..f40457cd0e8e 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -266,7 +266,10 @@ public override void VisitSwitch(ISwitchOperation operation) { Visit(operation.Value); var initialProvenance = CloneProvenance(); - var mergedProvenance = CloneProvenance(); + Dictionary? mergedProvenance = operation.Cases.Any( + @case => @case.Clauses.Any(clause => clause.CaseKind == CaseKind.Default)) + ? null + : CloneProvenance(); foreach (var @case in operation.Cases) { @@ -279,14 +282,19 @@ public override void VisitSwitch(ISwitchOperation operation) } var caseProvenance = CloneProvenance(); + mergedProvenance = MergeProvenance(mergedProvenance, caseProvenance); + } + if (mergedProvenance is null) + { + RestoreProvenance(initialProvenance); + _pathTerminated = true; + } + else + { RestoreProvenance(mergedProvenance); - MergeProvenance(caseProvenance); - mergedProvenance = CloneProvenance(); + _pathTerminated = false; } - - RestoreProvenance(mergedProvenance); - _pathTerminated = false; } public override void VisitSwitchCase(ISwitchCaseOperation operation) diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 79b4f72c5783..2fe451332c36 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -672,6 +672,52 @@ void LocalFunction() Assert.Empty(analyzerDiagnostics); } + [Fact] + public async Task LocalFunctionWithOwningBuilderInUnreachableCodeAfterSwitch_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + while (GetCondition()) + { + switch (GetValue()) + { + case 0: + continue; + default: + continue; + } + +#pragma warning disable CS0162 + alias = builder; +#pragma warning restore CS0162 + } + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; + private int GetValue() => 0; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + [Fact] public async Task NestedLocalFunctionWithFreshCapturedBuilder_NoDiagnostic() { From 0a53e1a6bf6f0329dff3be0ef5e1059395fdd5ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:51:15 +0000 Subject: [PATCH 14/15] Fix ASP0039 return path provenance Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- .../DoNotUseLocalFunctionsInMarkupAnalyzer.cs | 12 ++ .../DoNotUseLocalFunctionsInMarkupTest.cs | 111 +++++++++++++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs index f40457cd0e8e..3ba4463200a1 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -153,6 +153,8 @@ public override void VisitLocalFunction(ILocalFunctionOperation operation) public override void VisitAnonymousFunction(IAnonymousFunctionOperation operation) { var previousProvenance = CloneProvenance(); + var previousPathTerminated = _pathTerminated; + _pathTerminated = false; foreach (var parameter in operation.Symbol.Parameters) { _provenance[parameter] = false; @@ -160,6 +162,7 @@ public override void VisitAnonymousFunction(IAnonymousFunctionOperation operatio Visit(operation.Body); RestoreProvenance(previousProvenance); + _pathTerminated = previousPathTerminated; } public override void VisitVariableDeclarator(IVariableDeclaratorOperation operation) @@ -222,6 +225,12 @@ public override void VisitBranch(IBranchOperation operation) } } + public override void VisitReturn(IReturnOperation operation) + { + Visit(operation.ReturnedValue); + _pathTerminated = true; + } + public override void VisitConditional(IConditionalOperation operation) { Visit(operation.Condition); @@ -440,7 +449,9 @@ localFunction.Body is null || } var previousLocalFunction = _currentLocalFunction; + var previousPathTerminated = _pathTerminated; _currentLocalFunction = localFunction.Symbol; + _pathTerminated = false; foreach (var parameter in localFunction.Symbol.Parameters) { _provenance[parameter] = false; @@ -448,6 +459,7 @@ localFunction.Body is null || Visit(localFunction.Body); + _pathTerminated = previousPathTerminated; _currentLocalFunction = previousLocalFunction; _activeLocalFunctions.Remove(localFunction.Symbol); } diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs index 2fe451332c36..cd924b8a63d4 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -718,6 +718,112 @@ void LocalFunction() Assert.Empty(analyzerDiagnostics); } + [Fact] + public async Task LocalFunctionWithOwningBuilderOnlyOnReturningBranch_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + if (GetCondition()) + { + alias = builder; + return; + } + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task ReturnInInvokedLocalFunctionDoesNotTerminateCaller_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + + void AssignOwningBuilder() + { + alias = builder; + return; + } + + AssignOwningBuilder(); + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task ReturnInAnonymousFunctionDoesNotTerminateCaller_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using System; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + Action callback = () => + { + return; + }; + + void /*MM*/LocalFunction() + { + builder.OpenElement(0, "div"); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + [Fact] public async Task NestedLocalFunctionWithFreshCapturedBuilder_NoDiagnostic() { @@ -993,7 +1099,10 @@ public async Task LocalFunctionInRazorGeneratedBuildRenderTree_ProducesDiagnosti var diagnostics = await Runner.GetDiagnosticsAsync(generatedCode); var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); - Assert.Equal("IssueSample.razor", Path.GetFileName(analyzerDiagnostic.Location.GetMappedLineSpan().Path)); + var mappedPath = analyzerDiagnostic.Location.GetMappedLineSpan().Path + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + Assert.Equal("IssueSample.razor", Path.GetFileName(mappedPath)); } [Fact] From afbb7375afac7f8b468467c0bf69dce3242a9c0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:43:54 +0000 Subject: [PATCH 15/15] Clarify ASP0039 diagnostic title Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com> --- src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx index aa67b460dd15..2028ae0153f0 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx @@ -334,7 +334,7 @@ If the server does not specifically reject IPv6, IPAddress.IPv6Any is preferred over IPAddress.Any usage for safety and performance reasons. See https://aka.ms/aspnetcore-warnings/ASP0028 for more details. - Do not use local functions in markup + Do not access a captured RenderTreeBuilder from a local function Local function '{0}' accesses RenderTreeBuilder from parent scope, which can cause incorrect rendering behavior. Consider making it a static method or regular instance method that takes RenderTreeBuilder as a parameter.