Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using ErrorOr.Generators;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace ErrorOr.Analyzers;

/// <summary>
/// Body-source counting (EOE006) and DataAnnotations reflection check (EOE039) for the
/// <see cref="ErrorOrEndpointAnalyzer"/>. Body classification routes through
/// <see cref="ErrorOrContext"/> so analyzer and generator share one source of truth
/// (FQN + inheritance, not name-substring match).
/// </summary>
public sealed partial class ErrorOrEndpointAnalyzer
{
private static int CountBodySources(IMethodSymbol method)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH RISK

The body source counting logic should account for implicit DTO parameters (complex types not bound elsewhere) and must verify that parameters of type Stream or IFormFile are not explicitly bound to other sources like [FromServices] or [FromRoute] before counting them as body sources.

{
var bodyCount = 0;
var hasFromForm = false;
var hasStream = false;

foreach (var param in method.Parameters)
{
// Check for body-related attributes using HasAttribute
if (param.HasAttribute(WellKnownTypes.FromBodyAttribute))
{
bodyCount++;
continue;
}

if (param.HasAttribute(WellKnownTypes.FromFormAttribute))
{
hasFromForm = true;
continue;
}

// Body-related types: route through ErrorOrContext so analyzer + generator
// share one source of truth (FQN + inheritance, not name substring match).
if (ErrorOrContext.IsStream(param.Type) || ErrorOrContext.IsPipeReader(param.Type))
hasStream = true;
else if (ErrorOrContext.IsFormFile(param.Type) ||
ErrorOrContext.IsFormFileCollection(param.Type) ||
ErrorOrContext.IsFormCollection(param.Type))
hasFromForm = true;
}

// Multiple [FromBody] is always an error
if (bodyCount > 1) return bodyCount;

// Otherwise return number of distinct body source buckets used
return (bodyCount > 0 ? 1 : 0) + (hasFromForm ? 1 : 0) + (hasStream ? 1 : 0);
}

private static bool HasAcceptedResponseAttribute(ISymbol method)
{
return method.HasAttribute(WellKnownTypes.AcceptedResponseAttribute);
}

/// <summary>
/// Checks if any parameter has validation attributes from System.ComponentModel.DataAnnotations.
/// Validator.TryValidateObject uses reflection internally.
/// </summary>
private static void CheckForValidationAttributes(
in SymbolAnalysisContext context,
IMethodSymbol method)
{
var validationAttributeType = context.Compilation.GetTypeByMetadataName(WellKnownTypes.ValidationAttribute);
if (validationAttributeType is null) return;

foreach (var param in method.Parameters)
{
foreach (var attr in param.GetAttributes())
{
if (attr.AttributeClass is null) continue;

// Check if the attribute inherits from ValidationAttribute
if (InheritsFrom(attr.AttributeClass, validationAttributeType))
{
context.ReportDiagnostic(Diagnostic.Create(
Descriptors.ValidationUsesReflection,
param.Locations.FirstOrDefault() ?? method.Locations.FirstOrDefault(),
param.Name,
method.Name));
break; // Only report once per parameter
}
}
Comment on lines +69 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

EOE039 misses validation attributes on request DTO properties.

This only inspects param.GetAttributes(). The generator stack already collects validation properties for emitted DataAnnotations validation, so [Required]/[Range] on a body or query DTO will bypass the analyzer while still taking the reflection path at generation/runtime. Traverse the parameter type's reachable properties as well, with cycle protection, before deciding EOE039 is absent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.BodyAndValidation.cs`
around lines 69 - 85, The current loop only checks param.GetAttributes() so
EOE039 misses ValidationAttributes on DTO properties; update the logic around
the parameter inspection (the foreach over method.Parameters and the
InheritsFrom(...) check against validationAttributeType used when reporting
Descriptors.ValidationUsesReflection) to also traverse the parameter type's
reachable properties and their attributes (starting from the parameter's
ITypeSymbol/INamedTypeSymbol), using a visited set to protect against cycles,
and treat any property attribute that InheritsFrom(validationAttributeType) as
equivalent to a parameter attribute so the analyzer reports the EOE039
diagnostic (use param.Locations.FirstOrDefault() or
method.Locations.FirstOrDefault() as before) once per parameter.

}
}

/// <summary>
/// Checks if a type inherits from a base type.
/// </summary>
private static bool InheritsFrom(ITypeSymbol type, ISymbol baseType)
{
var current = type.BaseType;
while (current is not null)
{
if (SymbolEqualityComparer.Default.Equals(current, baseType)) return true;

current = current.BaseType;
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using ANcpLua.Roslyn.Utilities;
using ErrorOr.Generators;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace ErrorOr.Analyzers;

/// <summary>
/// Route pattern validation and route-constraint type checking. Hosts <c>EOE005</c>
/// (pattern syntax) and <c>EOE020</c> (constraint vs. CLR-type mismatch) for the
/// <see cref="ErrorOrEndpointAnalyzer"/>.
/// </summary>
public sealed partial class ErrorOrEndpointAnalyzer
{
/// <summary>
/// Validates route constraint types match method parameter types (EOE020).
/// </summary>
private static void ValidateConstraintTypes(
in SymbolAnalysisContext context,
ImmutableArray<RouteParameterInfo> routeParams,
IReadOnlyDictionary<string, RouteMethodParameterInfo> methodParamsByRouteName,
Location attributeLocation)
{
foreach (var rp in routeParams)
ValidateSingleRouteConstraint(in context, rp, methodParamsByRouteName, attributeLocation);
}

/// <summary>
/// Validates a single route parameter constraint against its bound method parameter.
/// </summary>
private static void ValidateSingleRouteConstraint(
in SymbolAnalysisContext context,
RouteParameterInfo rp,
IReadOnlyDictionary<string, RouteMethodParameterInfo> methodParamsByRouteName,
Location attributeLocation)
{
// Skip if no constraint or not bound to a method parameter
if (rp.Constraint is not { } constraint ||
!methodParamsByRouteName.TryGetValue(rp.Name, out var mp))
{
return;
}

if (mp.TypeFqn is not { } typeFqn) return;

// Skip format-only constraints
if (IsFormatOnlyConstraint(constraint)) return;

// Validate based on constraint type
if (rp.IsCatchAll)
ValidateCatchAllConstraint(in context, rp, mp, typeFqn, attributeLocation);
else
ValidateTypedConstraint(in context, rp, constraint, mp, typeFqn, attributeLocation);
}

/// <summary>
/// Checks if a constraint is format-only and doesn't constrain the CLR type.
/// Delegates to shared RouteValidator to avoid duplication.
/// </summary>
private static bool IsFormatOnlyConstraint(string constraint)
{
return RouteValidator.FormatOnlyConstraints.Contains(constraint);
}

/// <summary>
/// Validates that a catch-all parameter is bound to a string type.
/// </summary>
private static void ValidateCatchAllConstraint(
in SymbolAnalysisContext context,
RouteParameterInfo rp,
RouteMethodParameterInfo mp,
string typeFqn,
Location attributeLocation)
{
if (!IsStringType(typeFqn))
{
context.ReportDiagnostic(Diagnostic.Create(
Descriptors.RouteConstraintTypeMismatch,
attributeLocation,
rp.Name,
"*",
"string",
mp.Name,
NormalizeTypeName(typeFqn)));
}
}

/// <summary>
/// Validates that a typed constraint matches the bound parameter type.
/// Uses shared RouteValidator.ConstraintToTypes to avoid duplication.
/// </summary>
private static void ValidateTypedConstraint(
in SymbolAnalysisContext context,
RouteParameterInfo rp,
string constraint,
RouteMethodParameterInfo mp,
string typeFqn,
Location attributeLocation)
{
// Look up expected types for this constraint using shared RouteValidator
if (!RouteValidator.ConstraintToTypes.TryGetValue(constraint,
out var expectedTypes))
{
return; // Unknown constraint (e.g., custom) - skip validation
}

// Get the actual type, unwrapping Nullable<T> for optional parameters
var actualTypeFqn = typeFqn.UnwrapNullable(rp.IsOptional || mp.IsNullable);

// Check if actual type matches any expected type
if (!DoesTypeMatchConstraint(actualTypeFqn, expectedTypes))
{
context.ReportDiagnostic(Diagnostic.Create(
Descriptors.RouteConstraintTypeMismatch,
attributeLocation,
rp.Name,
constraint,
expectedTypes[0],
mp.Name,
NormalizeTypeName(typeFqn)));
}
}

/// <summary>
/// Checks if an actual type matches any of the expected types for a constraint.
/// </summary>
private static bool DoesTypeMatchConstraint(string actualTypeFqn, IEnumerable<string> expectedTypes)
{
foreach (var expected in expectedTypes)
{
if (TypeNamesMatch(actualTypeFqn, expected))
Comment on lines +107 to +131
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== RouteMethodParameterInfo definition =="
rg -n -C3 'RouteMethodParameterInfo' src/ErrorOrX.Generators

echo
echo "== String-based type comparison / nullable unwrapping in generator sources =="
rg -n -C2 'TypeNamesEqual\(|UnwrapNullable\(|NormalizeTypeName\(' src/ErrorOrX.Generators

echo
echo "== ErrorOrContext nullable helpers =="
rg -n -C2 'class ErrorOrContext|UnwrapNullable' src/ErrorOrX.Generators

Repository: ANcpLua/ErrorOrX

Length of output: 32891


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Call sites of ValidateConstraintTypes in analyzer =="
rg -n -B2 -A2 'ValidateConstraintTypes\(' src/ErrorOrX.Generators/Analyzers/

echo
echo "== ErrorOrEndpointAnalyzer main entry point =="
rg -n 'AnalyzeSymbol|SymbolAnalysisContext' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs | head -20

echo
echo "== How method symbol is accessed in analyzer =="
rg -n 'IMethodSymbol\|context\.' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.RouteValidation.cs | head -15

Repository: ANcpLua/ErrorOrX

Length of output: 1504


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== AnalyzeMethod signature and how it uses context =="
rg -n -A30 'private static void AnalyzeMethod' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs

echo
echo "== What does SymbolAnalysisContext expose =="
rg -n 'context\.Symbol\|context\.Compilation' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs | head -10

Repository: ANcpLua/ErrorOrX

Length of output: 1492


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Full AnalyzeEndpoint method =="
rg -n -A50 'private static void AnalyzeEndpoint' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs | head -80

echo
echo "== RouteMethodParameterInfo creation =="
rg -n -B5 -A10 'BuildRouteParameterLookup.*methodParams' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs

Repository: ANcpLua/ErrorOrX

Length of output: 2518


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== RouteBindingHelper.BindRouteParameters implementation =="
rg -n -A50 'public static.*BindRouteParameters' src/ErrorOrX.Generators/Validation/RouteBindingHelper.cs | head -70

echo
echo "== Where RouteMethodParameterInfo is constructed =="
rg -n -B3 -A3 'new RouteMethodParameterInfo' src/ErrorOrX.Generators/

Repository: ANcpLua/ErrorOrX

Length of output: 2973


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== EndpointParameter definition =="
rg -n -A15 'record.*EndpointParameter|struct.*EndpointParameter' src/ErrorOrX.Generators/Models/

echo
echo "== ErrorOrEndpointGenerator.BindParameters implementation (first 100 lines) =="
rg -n -A100 'public static.*BindParameters' src/ErrorOrX.Generators/Core/ErrorOrEndpointGenerator.ParameterBinding.cs | head -120

Repository: ANcpLua/ErrorOrX

Length of output: 2235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for BindParameters method =="
rg -n 'BindParameters' src/ErrorOrX.Generators/Core/ --type cs

echo
echo "== Search for where EndpointParameter is created from IParameterSymbol =="
rg -n -B3 -A8 'IParameterSymbol' src/ErrorOrX.Generators/Core/ErrorOrEndpointGenerator.ParameterBinding.cs | head -100

Repository: ANcpLua/ErrorOrX

Length of output: 1656


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== RouteValidator.ValidateConstraintTypes (generator phase) =="
rg -n -A30 'public static.*ValidateConstraintTypes' src/ErrorOrX.Generators/Validation/RouteValidator.cs | head -50

echo
echo "== Check if RouteValidator uses symbols or strings =="
rg -n 'MatchesExpectedType\|TypeNamesEqual\|NormalizeTypeName' src/ErrorOrX.Generators/Validation/RouteValidator.cs

Repository: ANcpLua/ErrorOrX

Length of output: 1780


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== How is IMethodSymbol.Parameters used in the generator =="
rg -n 'method\.Parameters' src/ErrorOrX.Generators/Core/ | head -20

echo
echo "== Does RouteValidator have access to method symbol in generator phase? =="
rg -n -B5 'ValidateConstraintTypes.*method' src/ErrorOrX.Generators/Validation/RouteValidator.cs | head -30

echo
echo "== Can we retrieve parameter symbol from method given parameter name? =="
rg -n 'method\.Parameters.*FirstOrDefault\|method\.Parameters.*Where' src/ErrorOrX.Generators/

Repository: ANcpLua/ErrorOrX

Length of output: 2148


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Analyzer call: does it have access to method.Parameters? =="
rg -n -B10 -A10 'ValidateConstraintTypes.*in context' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs | grep -A20 'AnalyzeEndpoint'

echo
echo "== Check if method symbol is available when ValidateConstraintTypes is called =="
rg -n -B15 'ValidateConstraintTypes\(in context' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs

echo
echo "== Full context of where ValidateConstraintTypes is invoked in analyzer =="
sed -n '110,150p' src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs

Repository: ANcpLua/ErrorOrX

Length of output: 123


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Get lines around the ValidateConstraintTypes invocation in analyzer =="
cat -n src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs | sed -n '130,160p'

echo
echo "== Get the full AnalyzeEndpoint method =="
cat -n src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.cs | sed -n '87,175p'

Repository: ANcpLua/ErrorOrX

Length of output: 5796


Pass the method symbol to route-constraint validation and use Roslyn type comparison.

The analyzer calls ValidateConstraintTypes without passing method, forcing constraint validation to work entirely on string-based type FQNs. Line 108 unwraps via typeFqn.UnwrapNullable(...) and lines 127–135 compare via TypeNamesEqual(...), both violating the repo guidelines requiring symbol-based semantics.

The method symbol is available in AnalyzeEndpoint (line 89) but not forwarded to ValidateConstraintTypes (line 143). Either extend RouteMethodParameterInfo to carry the resolved ITypeSymbol for each parameter, or pass method explicitly and resolve parameter types within validation. Then use ErrorOrContext.UnwrapNullable(ITypeSymbol) and Roslyn ITypeSymbol.Equals() for constraint matching.

String-based type comparison can drift on generic aliases, nullable lifting semantics, and nested type normalization, leading to inconsistent EOE020 diagnostics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ErrorOrX.Generators/Analyzers/ErrorOrEndpointAnalyzer.RouteValidation.cs`
around lines 107 - 131, The validator currently unwraps and compares types by
FQN strings in ValidateConstraintTypes/DoesTypeMatchConstraint (using
TypeNamesMatch), which can misrepresent semantics; update the call-site in
AnalyzeEndpoint to pass the method symbol (or extend RouteMethodParameterInfo to
include the resolved ITypeSymbol for each parameter) so validation can resolve
parameter types via ErrorOrContext.UnwrapNullable(ITypeSymbol) and perform
comparisons with ITypeSymbol.Equals (or SymbolEqualityComparer) instead of
string equality; adjust ValidateConstraintTypes and DoesTypeMatchConstraint to
accept ITypeSymbol(s) and use Roslyn type equality for constraint matching.

return true;
}

return false;
}

private static List<string> ValidateRoutePattern(string pattern)
{
var issues = new List<string>();

if (string.IsNullOrWhiteSpace(pattern))
{
issues.Add("Route pattern cannot be empty");
return issues;
}

// Strip escaped braces before validation (matches RouteValidator behavior)
// This prevents false positives for routes like /api/{{version}}/users
var escapedStripped = pattern.Replace("{{", "").Replace("}}", "");

// Check for empty parameter names: {}
if (escapedStripped.Contains("{}"))
issues.Add("Route contains empty parameter '{}'. Parameter names are required");

// Check for unclosed braces
var openCount = escapedStripped.Count(static c => c == '{');
var closeCount = escapedStripped.Count(static c => c == '}');
if (openCount != closeCount) issues.Add($"Route has mismatched braces: {openCount} '{{' and {closeCount} '}}'");

// Check for duplicate parameter names using RouteValidator (single source of truth)
var paramNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rp in RouteValidator.ExtractRouteParameters(pattern))
{
if (!paramNames.Add(rp.Name))
issues.Add($"Route contains duplicate parameter '{{{rp.Name}}}'");
}

return issues;
}

private static bool IsStringType(string typeFqn)
{
return typeFqn.IsStringType();
}

private static bool TypeNamesMatch(string actualFqn, string expected)
{
return actualFqn.TypeNamesEqual(expected);
}

private static string NormalizeTypeName(string typeFqn)
{
return typeFqn.NormalizeTypeName();
}
}
Loading
Loading