diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs index 5b1973b..3e8b57d 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer.Test/DateTimeConversionTests.cs @@ -205,6 +205,128 @@ static void Main(string[] args) } + [TestMethod] + public void UsageOfDateTimeInWhereLambda_DoesNotTriggerWarning_KnownLimitation() + { + // NOTE: This test documents a known limitation of the Roslyn operation-based analyzer. + // INTL0202 currently does NOT trigger for implicit conversions in LINQ extension method lambdas + // (e.g., System.Linq.Enumerable.Where) due to how Roslyn handles conversion operations + // in these contexts. + // + // Related: https://github.com/dotnet/roslyn/issues/14722 + // + // This limitation affects: + // - Extension methods from System.Linq.Enumerable (Where, Select, etc.) + // - Potentially other extension method lambdas + // + // The analyzer DOES work correctly for: + // - Direct comparisons (e.g., if (dateTime < dateTimeOffset)) + // - Local function calls (see UsageOfDateTimeInLocalFunction_ProducesWarningMessage) + // - Instance method lambdas (e.g., List.RemoveAll - see UsageOfDateTimeInRemoveAllLambda_ProducesWarningMessage) + // + // TODO: Investigate syntax-based analysis or other workarounds to detect these cases + + string source = @" +using System; +using System.Linq; +using System.Collections.Generic; + +namespace ConsoleApp48 +{ + class Program + { + static void Main(string[] args) + { + var list = new List(); + DateTime dt = DateTime.Now; + + _ = list.Where(item => item < dt); + } + } +}"; + + // Currently no warnings - this documents the known limitation + // Explicitly expecting zero diagnostics + VerifyCSharpDiagnostic(source); + } + + [TestMethod] + public void UsageOfDateTimeInRemoveAllLambda_ProducesWarningMessage() + { + // This test shows that RemoveAll lambda DOES trigger warning + string source = @" +using System; +using System.Collections.Generic; + +namespace ConsoleApp48a +{ + class Program + { + static void Main(string[] args) + { + var list = new List(); + DateTime dt = DateTime.Now; + + list.RemoveAll(item => item < dt); + } + } +}"; + + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' or 'new DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 14, 43) + ] + }); + + } + + [TestMethod] + public void UsageOfDateTimeInLocalFunction_ProducesWarningMessage() + { + string source = @" +using System; +using System.Linq; +using System.Collections.Generic; + +namespace ConsoleApp49 +{ + class Program + { + static void Main(string[] args) + { + var list = new List(); + DateTime dt = DateTime.Now; + + bool Compare(DateTimeOffset item) + { + return item < dt; + } + + var query = list.Where(Compare); + } + } +}"; + + VerifyCSharpDiagnostic(source, + new DiagnosticResult + { + Id = "INTL0202", + Severity = DiagnosticSeverity.Warning, + Message = "Using 'DateTimeOffset.implicit operator DateTimeOffset(DateTime)' or 'new DateTimeOffset(DateTime)' can result in unpredictable behavior", + Locations = + [ + new DiagnosticResultLocation("Test0.cs", 17, 31) + ] + }); + + } + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() { return new Analyzers.BanImplicitDateTimeToDateTimeOffsetConversion(); diff --git a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs index 303e11f..c91c2e4 100644 --- a/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs +++ b/IntelliTect.Analyzer/IntelliTect.Analyzer/Analyzers/BanImplicitDateTimeToDateTimeOffsetConversion.cs @@ -28,42 +28,67 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); context.EnableConcurrentExecution(); - context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Conversion); - context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation); + + context.RegisterCompilationStartAction(compilationContext => + { + INamedTypeSymbol dateTimeOffsetType = compilationContext.Compilation.GetTypeByMetadataName("System.DateTimeOffset"); + INamedTypeSymbol dateTimeType = compilationContext.Compilation.GetTypeByMetadataName("System.DateTime"); + + if (dateTimeOffsetType is null || dateTimeType is null) + { + return; + } + + compilationContext.RegisterOperationAction( + operationContext => AnalyzeConversion(operationContext, dateTimeOffsetType, dateTimeType), + OperationKind.Conversion); + compilationContext.RegisterOperationAction( + operationContext => AnalyzeObjectCreation(operationContext, dateTimeOffsetType, dateTimeType), + OperationKind.ObjectCreation); + }); } - private void AnalyzeInvocation(OperationAnalysisContext context) + + private void AnalyzeConversion(OperationAnalysisContext context, INamedTypeSymbol dateTimeOffsetType, INamedTypeSymbol dateTimeType) { if (context.Operation is not IConversionOperation conversionOperation) { return; } - if (conversionOperation.Conversion.IsImplicit && conversionOperation.Conversion.MethodSymbol is object && conversionOperation.Conversion.MethodSymbol.ContainingType is object) + if (!conversionOperation.Conversion.IsImplicit) + { + return; + } + + // Check via method symbol (original logic) + if (conversionOperation.Conversion.MethodSymbol is object && + conversionOperation.Conversion.MethodSymbol.ContainingType is object) { INamedTypeSymbol containingType = conversionOperation.Conversion.MethodSymbol.ContainingType; - INamedTypeSymbol dateTimeOffsetType = context.Compilation.GetTypeByMetadataName("System.DateTimeOffset"); if (SymbolEqualityComparer.Default.Equals(containingType, dateTimeOffsetType)) { context.ReportDiagnostic(Diagnostic.Create(_Rule202, conversionOperation.Syntax.GetLocation())); + return; } } - + // Fallback: Check via operand and type information + // This handles cases where MethodSymbol might not be populated (e.g., in some lambda contexts) + if (SymbolEqualityComparer.Default.Equals(conversionOperation.Operand?.Type, dateTimeType) && + SymbolEqualityComparer.Default.Equals(conversionOperation.Type, dateTimeOffsetType)) + { + context.ReportDiagnostic(Diagnostic.Create(_Rule202, conversionOperation.Syntax.GetLocation())); + } } - private void AnalyzeObjectCreation(OperationAnalysisContext context) + private void AnalyzeObjectCreation(OperationAnalysisContext context, INamedTypeSymbol dateTimeOffsetType, INamedTypeSymbol dateTimeType) { if (context.Operation is not IObjectCreationOperation objectCreation) { return; } - INamedTypeSymbol dateTimeOffsetType = context.Compilation.GetTypeByMetadataName("System.DateTimeOffset") - ?? throw new InvalidOperationException("Unable to find DateTimeOffset type"); - INamedTypeSymbol dateTimeType = context.Compilation.GetTypeByMetadataName("System.DateTime") - ?? throw new InvalidOperationException("Unable to find DateTime type"); - // Check if we're creating a DateTimeOffset if (!SymbolEqualityComparer.Default.Equals(objectCreation.Type, dateTimeOffsetType)) {