Skip to content
3 changes: 3 additions & 0 deletions .github/instructions/Razor.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ their original sub-tree layout
The bug is more likely in existing logic than a missing feature.
- **Helpers**: Review existing helpers (`UsingDirectiveHelper`, `AddUsingsHelper`, etc.)
before writing new utility methods. Don't duplicate.
- **Warning levels**: Track warnings with non-zero `RazorWarningLevel` values in
[`docs/razor/warning-levels.md`](../../docs/razor/warning-levels.md), including the diagnostic
ID, warning level, exact message, and trigger condition.

## File Types

Expand Down
7 changes: 3 additions & 4 deletions docs/razor/WarningWavesProposal.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Proposal: Warning Levels for Razor

> **Status:** The infrastructure described here was implemented in [dotnet/razor#13016](https://github.com/dotnet/razor/pull/13016) and ships in Razor 18.7. The shipped feature is named **warning levels** (`RazorWarningLevel`) rather than warning waves. No new warnings have been classified at non-default levels yet — that work is layered on top of this infrastructure as warnings are introduced.
> **Status:** The infrastructure described here was implemented in [dotnet/razor#13016](https://github.com/dotnet/razor/pull/13016) and ships in Razor 18.7. The shipped feature is named **warning levels** (`RazorWarningLevel`) rather than warning waves. Warnings classified at non-default levels are tracked in [Razor warning levels](warning-levels.md).

## Summary

Expand Down Expand Up @@ -40,7 +40,7 @@ Each warning is tagged with an integer **warning level**. The compiler reports a

**Default behaviour.** When `RazorWarningLevel` is not set, the compiler uses `RazorLanguageVersion.GetDefaultWarningLevel()`, which currently returns the language version's major number. So a project on Razor 11 implicitly gets `RazorWarningLevel = 11` and therefore sees every level <= 11.

**Existing warnings are unaffected.** All warnings that exist today were authored without specifying a level, so they default to level `0` and continue to be reported regardless of the configured `RazorWarningLevel`. No project sees a behavior change until new warnings are added at higher levels.
**Existing level-0 warnings are unaffected.** Warnings authored without specifying a level default to level `0` and continue to be reported regardless of the configured `RazorWarningLevel`. Warning-wave diagnostics use non-zero levels and are filtered as described above.

### Configuration

Expand Down Expand Up @@ -148,12 +148,11 @@ The infrastructure landed in [#13016](https://github.com/dotnet/razor/pull/13016
- ✅ `CodeRenderingContext.GetDiagnostics()` filters warnings by level.
- ✅ `RZ3601` reported for invalid `RazorWarningLevel` values.
- ✅ `RazorLanguageVersion.GetDefaultWarningLevel()` defines the default (currently `Major`).
- ✅ Non-zero-level warnings are catalogued in [Razor warning levels](warning-levels.md).

Still to do (follow-up work):

- Author the first batch of non-zero-level warnings.
- IDE/tooling integration so live diagnostics also respect the configured level.
- User-facing documentation listing each warning and the level at which it was introduced.

## Open Questions

Expand Down
15 changes: 15 additions & 0 deletions docs/razor/warning-levels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Razor warning levels

Razor diagnostics introduced in warning waves are assigned a non-zero warning level. A diagnostic
is reported when its level is less than or equal to the configured `RazorWarningLevel`. See the
[warning levels proposal](WarningWavesProposal.md) for the design and configuration details.

## Warnings

| Diagnostic | Warning level | Message | When reported |
|------------|---------------|---------|---------------|
| `RZ3907` | 11 | `The '@model' directive is not applied to the generated base class because the '@inherits' directive does not contain '<TModel>'.` | An MVC view has an explicit `@model` directive and an `@inherits` directive without the literal `<TModel>` placeholder. |
| `RZ10025` | 11 | `The component '{0}' does not have a parameter named '{1}'.` | An explicit attribute on a resolved component does not bind to a known component parameter, and no valid capture-unmatched-values parameter can accept it. |
| `RZ10026` | 11 | `The bind attribute '{0}' does not match any parameter on component '{1}'.` | A component `@bind-*` attribute has a statically known target name that does not match a component parameter. |
| `RZ10027` | 11 | `The bind attribute '{0}' requires a matching change parameter named '{1}' on component '{2}'.` | A component bind target exists, but its statically known companion change parameter does not. |
| `RZ10028` | 11 | `The attribute '{0}' could not be bound to any directive attribute.` | A parser-recognized directive attribute in a component document reaches tag-helper resolution without any semantic bound-attribute match. |
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// 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.Globalization;
using Roslyn.Test.Utilities;
using Xunit;

namespace Microsoft.AspNetCore.Razor.Language.IntegrationTests;
Expand Down Expand Up @@ -134,4 +136,321 @@ @using Test
Assert.Equal("Found a malformed 'InputText' tag helper. Tag helpers must have a start and end tag or be self closing.", diagnostic.GetMessage(CultureInfo.CurrentCulture));
});
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_MissingTarget_NoWarningAtAnalysisLevel10()
{
Comment thread
davidwengier marked this conversation as resolved.
AdditionalSyntaxTrees.Add(Parse("""
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
}
"""));

var generated = CompileToCSharp(
"""
@using Test
<MyComponent @bind-Missing="ParentValue" />

@code {
public int ParentValue { get; set; }
}
""",
configuration: Configuration with { RazorWarningLevel = 10 });

Assert.Empty(generated.RazorDiagnostics);
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_MissingTarget_ButAcceptsUnmatched_NoWarningAtAnalysisLevel11()
{
AdditionalSyntaxTrees.Add(Parse("""
using System.Collections.Generic;
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> AdditionalAttributes { get; set; }
}
"""));

var generated = CompileToCSharp(
"""
@using Test
<MyComponent @bind-Missing="ParentValue" />

@code {
public int ParentValue { get; set; }
}
""",
configuration: Configuration with { RazorWarningLevel = 11 });

Assert.Empty(generated.RazorDiagnostics);
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_MissingTarget_WarnsAtAnalysisLevel11()
{
AdditionalSyntaxTrees.Add(Parse("""
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
}
"""));

const string content = """
@using Test
<MyComponent @bind-Missing="ParentValue" />

@code {
public int ParentValue { get; set; }
}
""";

var generated = CompileToCSharp(
content,
configuration: Configuration with { RazorWarningLevel = 11 });

var diagnostic = Assert.Single(generated.RazorDiagnostics);
Assert.Equal("RZ10026", diagnostic.Id);
Assert.Equal(RazorDiagnosticSeverity.Warning, diagnostic.Severity);
Assert.Equal(11, diagnostic.WarningLevel);
Assert.Equal(
"The bind attribute '@bind-Missing' does not match any parameter on component 'MyComponent'.",
diagnostic.GetMessage(CultureInfo.CurrentCulture));
AssertDiagnosticSpan(content, diagnostic, "Missing");
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_MissingChangeParameter_NoWarningAtAnalysisLevel10()
{
AdditionalSyntaxTrees.Add(Parse("""
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
[Parameter]
public int Value { get; set; }
}
"""));

var generated = CompileToCSharp(
"""
@using Test
<MyComponent @bind-Value="ParentValue" />

@code {
public int ParentValue { get; set; }
}
""",
configuration: Configuration with { RazorWarningLevel = 10 });

Assert.Empty(generated.RazorDiagnostics);
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_MissingChangeParameter_WarnsAtAnalysisLevel11()
{
AdditionalSyntaxTrees.Add(Parse("""
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
[Parameter]
public int Value { get; set; }
}
"""));

const string content = """
@using Test
<MyComponent @bind-Value="ParentValue" />

@code {
public int ParentValue { get; set; }
}
""";

var generated = CompileToCSharp(
content,
configuration: Configuration with { RazorWarningLevel = 11 });

var diagnostic = Assert.Single(generated.RazorDiagnostics);
Assert.Equal("RZ10027", diagnostic.Id);
Assert.Equal(RazorDiagnosticSeverity.Warning, diagnostic.Severity);
Assert.Equal(11, diagnostic.WarningLevel);
Assert.Equal(
"The bind attribute '@bind-Value' requires a matching change parameter named 'ValueChanged' on component 'MyComponent'.",
diagnostic.GetMessage(CultureInfo.CurrentCulture));
AssertDiagnosticSpan(content, diagnostic, "Value");
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_MissingChangeParameter_ButAcceptsUnmatched_NoWarningAtAnalysisLevel11()
{
AdditionalSyntaxTrees.Add(Parse("""
using System.Collections.Generic;
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
[Parameter]
public int Value { get; set; }

[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> AdditionalAttributes { get; set; }
}
"""));

var generated = CompileToCSharp(
"""
@using Test
<MyComponent @bind-Value="ParentValue" />

@code {
public int ParentValue { get; set; }
}
""",
configuration: Configuration with { RazorWarningLevel = 11 });

Assert.Empty(generated.RazorDiagnostics);
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_MissingExplicitChangeParameter_WarnsAtAnalysisLevel11()
{
AdditionalSyntaxTrees.Add(Parse("""
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
[Parameter]
public int Value { get; set; }
}
"""));

const string content = """
@using Test
<MyComponent @bind-Value="ParentValue" @bind-Value:event="OnChanged" />

@code {
public int ParentValue { get; set; }
}
""";

var generated = CompileToCSharp(
content,
configuration: Configuration with { RazorWarningLevel = 11 });

var diagnostic = Assert.Single(generated.RazorDiagnostics);
Assert.Equal("RZ10027", diagnostic.Id);
Assert.Equal(RazorDiagnosticSeverity.Warning, diagnostic.Severity);
Assert.Equal(11, diagnostic.WarningLevel);
Assert.Equal(
"The bind attribute '@bind-Value' requires a matching change parameter named 'OnChanged' on component 'MyComponent'.",
diagnostic.GetMessage(CultureInfo.CurrentCulture));
AssertDiagnosticSpan(content, diagnostic, "OnChanged");
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_DynamicEvent_NoWarningAtAnalysisLevel11()
{
AdditionalSyntaxTrees.Add(Parse("""
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
[Parameter]
public int Value { get; set; }
}
"""));

var generated = CompileToCSharp(
"""
@using Test
<MyComponent @bind-Value="ParentValue" @bind-Value:event="@EventName" />

@code {
public int ParentValue { get; set; }

private string EventName => "ValueChanged";
}
""",
configuration: Configuration with { RazorWarningLevel = 11 });

Assert.Empty(generated.RazorDiagnostics);
CompileToAssembly(generated);
}

[Fact, WorkItem("https://github.com/dotnet/razor/issues/13125")]
public void BindToComponent_ValidTargetsAndModifiers_NoWarningsAtAnalysisLevel11()
{
AdditionalSyntaxTrees.Add(Parse("""
using System;
using Microsoft.AspNetCore.Components;

namespace Test;

public class MyComponent : ComponentBase
{
[Parameter]
public int Value { get; set; }

[Parameter]
public Action<int> ValueChanged { get; set; }

[Parameter]
public Action<int> OnChanged { get; set; }
}
"""));

var generated = CompileToCSharp(
"""
@using Test
<MyComponent @bind-Value="ParentValue" />
<MyComponent @bind-Value="ParentValue" @bind-Value:event="OnChanged" />
<MyComponent @bind-Value:get="ParentValue" @bind-Value:set="UpdateValue" />
<MyComponent @bind-Value:get="ParentValue" @bind-Value:after="After" />

@code {
public int ParentValue { get; set; }

private void UpdateValue(int value) => ParentValue = value;

private void After()
{
}
}
""",
configuration: Configuration with { RazorWarningLevel = 11 });

Assert.Empty(generated.RazorDiagnostics);
CompileToAssembly(generated);
}

private static void AssertDiagnosticSpan(string content, RazorDiagnostic diagnostic, string expected)
{
var index = content.IndexOf(expected, StringComparison.Ordinal);
Assert.NotNull(diagnostic.Span.FilePath);
Assert.Equal(index, diagnostic.Span.AbsoluteIndex);
Assert.Equal(expected.Length, diagnostic.Span.Length);
}
}
Loading