diff --git a/Server.Tests/SdkAntiPatternValidatorTests.cs b/Server.Tests/SdkAntiPatternValidatorTests.cs new file mode 100644 index 0000000..89c26de --- /dev/null +++ b/Server.Tests/SdkAntiPatternValidatorTests.cs @@ -0,0 +1,884 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System.Collections.Immutable; + +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +using SdkLspServer.Diagnostics; +using SdkLspServer.Diagnostics.Validators; +using SdkLspServer.Services; + +namespace SdkLspServer.Tests; + +[TestClass] +public sealed class SdkAntiPatternValidatorTests +{ + /// + /// Preamble that defines fake SDK types in the Azure.Connectors.Sdk namespace + /// so that the semantic model resolves connector client methods during analysis. + /// + private const string SdkPreamble = """ + using System; + using System.Threading; + using System.Threading.Tasks; + namespace Azure.Connectors.Sdk + { + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConnectorOperationAttribute : Attribute + { + public string ConnectorName { get; set; } = ""; + public string OperationName { get; set; } = ""; + } + public class ConnectorException : Exception + { + public int StatusCode { get; set; } + } + } + namespace Azure.Connectors.Sdk.Office365 + { + public class Office365Client + { + public virtual Task SendEmailAsync( + string subject, + CancellationToken cancellationToken = default) => Task.FromResult(null); + + public virtual Task GetEmailAsync( + string emailId, + CancellationToken cancellationToken = default) => Task.FromResult(null); + + public virtual void SyncOperation(string id) { } + } + } + """; + + private static SdkIndex CreateMockSdkIndex() + { + return SdkIndex.CreateForTesting( + connectorNames: new[] + { + new SdkConstant("Office365", "office365", "ConnectorNames", "Azure.Connectors.Sdk.ConnectorNames"), + }, + triggerOperations: new Dictionary> + { + ["office365"] = ImmutableArray.Create( + new SdkConstant("OnNewEmail", "OnNewEmail", "Office365TriggerOperations", "Azure.Connectors.Sdk.Office365.Office365TriggerOperations")), + }, + typeNames: new[] + { + "Azure.Connectors.Sdk.Office365.Office365SendEmailInput", + "Azure.Connectors.Sdk.Office365.Office365SendEmailOutput", + }); + } + + private static SdkAntiPatternValidator CreateValidator(SdkIndex? sdkIndex = null) + { + var compilationService = new CompilationService(sdkIndex); + return new SdkAntiPatternValidator(compilationService); + } + + // --------------------------------------------------------------- + // CSDK401: [ConnectorOperation] unknown operation + // --------------------------------------------------------------- + [TestMethod] + public async Task ValidateAsync_ConnectorOperationUnknownOperation_EmitsCSdk401Async() + { + // Arrange: No ConnectorName — CSDK401 checks against all operations + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConnectorOperationAttribute : Attribute + { + public string OperationName { get; set; } = ""; + } + public class Test + { + [ConnectorOperation(OperationName = "NonexistentOp")] + public void MyMethod() { } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorOperationValueUnknown, StringComparison.Ordinal)); + Assert.IsNotNull(result, message: "Expected CSDK401 for unknown operation name."); + Assert.AreEqual(DiagnosticSeverity.Warning, result.Severity); + Assert.IsTrue(result.Message.Contains("NonexistentOp", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task ValidateAsync_ConnectorOperationKnownOperation_NoCSdk401Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConnectorOperationAttribute : Attribute + { + public string ConnectorName { get; set; } = ""; + public string OperationName { get; set; } = ""; + } + public class Test + { + [ConnectorOperation(ConnectorName = "office365", OperationName = "OnNewEmail")] + public void MyMethod() { } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorOperationValueUnknown, StringComparison.Ordinal)), + message: "Should not emit CSDK401 for a known operation."); + } + + [TestMethod] + public async Task ValidateAsync_ConnectorOperationWithConnectorName_SkipsCSdk401Async() + { + // Arrange: When ConnectorName is present, AttributeValidator handles validation + // (CSDK009) so SdkAntiPatternValidator skips to avoid duplicates. + var sdkIndex = SdkIndex.CreateForTesting( + connectorNames: new[] + { + new SdkConstant("Office365", "office365", "ConnectorNames", "Azure.Connectors.Sdk.ConnectorNames"), + new SdkConstant("Teams", "teams", "ConnectorNames", "Azure.Connectors.Sdk.ConnectorNames"), + }, + triggerOperations: new Dictionary> + { + ["office365"] = ImmutableArray.Create( + new SdkConstant("OnNewEmail", "OnNewEmail", "Office365TriggerOperations", "Azure.Connectors.Sdk.Office365.Office365TriggerOperations")), + ["teams"] = ImmutableArray.Create( + new SdkConstant("OnNewChannelMessage", "OnNewChannelMessage", "TeamsTriggerOperations", "Azure.Connectors.Sdk.Teams.TeamsTriggerOperations")), + }); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConnectorOperationAttribute : Attribute + { + public string ConnectorName { get; set; } = ""; + public string OperationName { get; set; } = ""; + } + public class Test + { + [ConnectorOperation(ConnectorName = "teams", OperationName = "OnNewEmail")] + public void MyMethod() { } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert — CSDK401 should NOT fire when ConnectorName is present (CSDK009 handles it) + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorOperationValueUnknown, StringComparison.Ordinal)), + message: "Should not emit CSDK401 when ConnectorName is present (AttributeValidator CSDK009 handles this)."); + } + + [TestMethod] + public async Task ValidateAsync_ConnectorOperationConstantReference_NoCSdk401Async() + { + // Arrange: When ConnectorName is present (including constant references), + // CSDK401 is skipped entirely — AttributeValidator (CSDK009) handles it. + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConnectorOperationAttribute : Attribute + { + public string ConnectorName { get; set; } = ""; + public string OperationName { get; set; } = ""; + } + public static class ConnectorNames { public const string Office365 = "office365"; } + public class Test + { + [ConnectorOperation(ConnectorName = ConnectorNames.Office365, OperationName = "OnNewEmail")] + public void MyMethod() { } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorOperationValueUnknown, StringComparison.Ordinal)), + message: "Should not emit CSDK401 when ConnectorName is a constant reference (FieldName form)."); + } + + [TestMethod] + public async Task ValidateAsync_ConnectorOperationPositionalArgument_EmitsCSdk401Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConnectorOperationAttribute : Attribute + { + public ConnectorOperationAttribute(string operationName) { OperationName = operationName; } + public string ConnectorName { get; set; } = ""; + public string OperationName { get; set; } = ""; + } + public class Test + { + [ConnectorOperation("UnknownPositionalOp")] + public void MyMethod() { } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorOperationValueUnknown, StringComparison.Ordinal)); + Assert.IsNotNull(result, message: "Expected CSDK401 for unknown positional operation name."); + } + + // --------------------------------------------------------------- + // CSDK402: Wrong payload type direction + // --------------------------------------------------------------- + [TestMethod] + public async Task ValidateAsync_InputTypeForAwaitResult_EmitsCSdk402Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System.Threading.Tasks; + public class Office365SendEmailInput { } + public class Office365SendEmailOutput { } + public class Test + { + public async Task Run() + { + Office365SendEmailInput result = await Task.FromResult(new Office365SendEmailInput()); + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result2 = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.WrongPayloadTypeDirection, StringComparison.Ordinal)); + Assert.IsNotNull(result2, message: "Expected CSDK402 for Input type receiving await result."); + Assert.AreEqual(DiagnosticSeverity.Information, result2.Severity); + Assert.IsTrue(result2.Message.Contains("Office365SendEmailOutput", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task ValidateAsync_OutputTypeForAwaitResult_NoCSdk402Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System.Threading.Tasks; + public class Office365SendEmailOutput { } + public class Test + { + public async Task Run() + { + Office365SendEmailOutput result = await Task.FromResult(new Office365SendEmailOutput()); + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.WrongPayloadTypeDirection, StringComparison.Ordinal)), + message: "Should not emit CSDK402 for correct Output type."); + } + + [TestMethod] + public async Task ValidateAsync_NullableInputTypeForAwaitResult_EmitsCSdk402Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System.Threading.Tasks; + public class Office365SendEmailInput { } + public class Office365SendEmailOutput { } + public class Test + { + public async Task Run() + { + Office365SendEmailInput? result = await Task.FromResult(null); + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result2 = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.WrongPayloadTypeDirection, StringComparison.Ordinal)); + Assert.IsNotNull(result2, message: "Expected CSDK402 for nullable Input type receiving await result."); + } + + // --------------------------------------------------------------- + // CSDK403: ConnectorException without StatusCode + // --------------------------------------------------------------- + [TestMethod] + public async Task ValidateAsync_CatchConnectorExceptionWithoutStatusCode_EmitsCSdk403Async() + { + // Arrange + var validator = SdkAntiPatternValidatorTests.CreateValidator(); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + public class ConnectorException : Exception + { + public int StatusCode { get; set; } + } + public class Test + { + public void Run() + { + try { } + catch (ConnectorException ex) + { + Console.WriteLine(ex.Message); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex: null, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorExceptionWithoutStatusCode, StringComparison.Ordinal)); + Assert.IsNotNull(result, message: "Expected CSDK403 for ConnectorException without StatusCode check."); + Assert.AreEqual(DiagnosticSeverity.Warning, result.Severity); + Assert.IsTrue(result.Message.Contains("StatusCode", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task ValidateAsync_CatchConnectorExceptionWithStatusCode_NoCSdk403Async() + { + // Arrange + var validator = SdkAntiPatternValidatorTests.CreateValidator(); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + public class ConnectorException : Exception + { + public int StatusCode { get; set; } + } + public class Test + { + public void Run() + { + try { } + catch (ConnectorException ex) + { + if (ex.StatusCode == 404) + { + Console.WriteLine("Not found."); + } + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex: null, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorExceptionWithoutStatusCode, StringComparison.Ordinal)), + message: "Should not emit CSDK403 when StatusCode is checked."); + } + + [TestMethod] + public async Task ValidateAsync_CatchConnectorExceptionWithConditionalStatusCode_NoCSdk403Async() + { + // Arrange + var validator = SdkAntiPatternValidatorTests.CreateValidator(); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + public class ConnectorException : Exception + { + public int StatusCode { get; set; } + } + public class Test + { + public void Run() + { + try { } + catch (ConnectorException ex) + { + var code = ex?.StatusCode; + Console.WriteLine(code); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex: null, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorExceptionWithoutStatusCode, StringComparison.Ordinal)), + message: "Should not emit CSDK403 when StatusCode is accessed via conditional access (ex?.StatusCode)."); + } + + [TestMethod] + public async Task ValidateAsync_CatchConnectorExceptionWithFilterStatusCode_NoCSdk403Async() + { + // Arrange + var validator = SdkAntiPatternValidatorTests.CreateValidator(); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + public class ConnectorException : Exception + { + public int StatusCode { get; set; } + } + public class Test + { + public void Run() + { + try { } + catch (ConnectorException ex) when (ex.StatusCode == 429) + { + Console.WriteLine("Throttled."); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex: null, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorExceptionWithoutStatusCode, StringComparison.Ordinal)), + message: "Should not emit CSDK403 when StatusCode is checked in catch filter (when clause)."); + } + + [TestMethod] + public async Task ValidateAsync_GlobalQualifiedConnectorException_EmitsCSdk403Async() + { + // Arrange: global::ConnectorException should be recognized + var validator = SdkAntiPatternValidatorTests.CreateValidator(); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + public class ConnectorException : Exception + { + public int StatusCode { get; set; } + } + public class Test + { + public void Run() + { + try { } + catch (global::ConnectorException ex) + { + Console.WriteLine(ex.Message); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex: null, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorExceptionWithoutStatusCode, StringComparison.Ordinal)); + Assert.IsNotNull(result, message: "Expected CSDK403 for global::ConnectorException without StatusCode check."); + } + + // --------------------------------------------------------------- + // CSDK404: Async connector call without await + // --------------------------------------------------------------- + [TestMethod] + public async Task ValidateAsync_AsyncConnectorCallWithoutAwait_EmitsCSdk404Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public void Run() + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + client.SendEmailAsync("test"); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.AsyncConnectorCallWithoutAwait, StringComparison.Ordinal)); + Assert.IsNotNull(result, message: "Expected CSDK404 for async connector call without await."); + Assert.AreEqual(DiagnosticSeverity.Warning, result.Severity); + Assert.IsTrue(result.Message.Contains("SendEmailAsync", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task ValidateAsync_AsyncConnectorCallWithAwait_NoCSdk404Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public async System.Threading.Tasks.Task Run() + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + await client.SendEmailAsync("test"); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.AsyncConnectorCallWithoutAwait, StringComparison.Ordinal)), + message: "Should not emit CSDK404 when await is used."); + } + + [TestMethod] + public async Task ValidateAsync_SyncConnectorCallWithoutAwait_NoCSdk404Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public void Run() + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + client.SyncOperation("test"); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.AsyncConnectorCallWithoutAwait, StringComparison.Ordinal)), + message: "Should not emit CSDK404 for sync methods."); + } + + [TestMethod] + public async Task ValidateAsync_ChainedConfigureAwaitWithoutAwait_EmitsCSdk404Async() + { + // Arrange: client.SendEmailAsync("test").ConfigureAwait(continueOnCapturedContext: false); + // without await should still detect the underlying SDK method. + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public void Run() + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + client.SendEmailAsync("test").ConfigureAwait(continueOnCapturedContext: false); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.AsyncConnectorCallWithoutAwait, StringComparison.Ordinal)); + Assert.IsNotNull(result, message: "Expected CSDK404 for chained ConfigureAwait without await."); + Assert.IsTrue(result.Message.Contains("SendEmailAsync", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task ValidateAsync_ChainedConfigureAwaitWithAwait_NoCSdk404Async() + { + // Arrange: await client.SendEmailAsync("test").ConfigureAwait(continueOnCapturedContext: false); + // should NOT emit CSDK404. + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public async System.Threading.Tasks.Task Run() + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + await client.SendEmailAsync("test").ConfigureAwait(continueOnCapturedContext: false); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.AsyncConnectorCallWithoutAwait, StringComparison.Ordinal)), + message: "Should not emit CSDK404 when chained ConfigureAwait is awaited."); + } + + // --------------------------------------------------------------- + // CSDK405: CancellationToken not passed + // --------------------------------------------------------------- + [TestMethod] + public async Task ValidateAsync_CancellationTokenNotPassed_EmitsCSdk405Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public async System.Threading.Tasks.Task Run(System.Threading.CancellationToken cancellationToken) + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + await client.SendEmailAsync("test"); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Diagnostic? result = diagnostics.FirstOrDefault(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.CancellationTokenNotPassed, StringComparison.Ordinal)); + Assert.IsNotNull(result, message: "Expected CSDK405 when CancellationToken is available but not passed."); + Assert.AreEqual(DiagnosticSeverity.Warning, result.Severity); + Assert.IsTrue(result.Message.Contains("cancellationToken", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task ValidateAsync_CancellationTokenPassed_NoCSdk405Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public async System.Threading.Tasks.Task Run(System.Threading.CancellationToken cancellationToken) + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + await client.SendEmailAsync("test", cancellationToken); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.CancellationTokenNotPassed, StringComparison.Ordinal)), + message: "Should not emit CSDK405 when CancellationToken is forwarded."); + } + + [TestMethod] + public async Task ValidateAsync_NoCancellationTokenInScope_NoCSdk405Async() + { + // Arrange + var sdkIndex = SdkAntiPatternValidatorTests.CreateMockSdkIndex(); + var validator = SdkAntiPatternValidatorTests.CreateValidator(sdkIndex); + var uri = DocumentUri.From("file:///test.cs"); + string code = SdkAntiPatternValidatorTests.SdkPreamble + """ + namespace TestApp + { + public class MyFunction + { + public async System.Threading.Tasks.Task Run() + { + var client = new Azure.Connectors.Sdk.Office365.Office365Client(); + await client.SendEmailAsync("test"); + } + } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.CancellationTokenNotPassed, StringComparison.Ordinal)), + message: "Should not emit CSDK405 when no CancellationToken is available."); + } + + // --------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------- + [TestMethod] + public async Task ValidateAsync_EmptyDocument_ReturnsEmptyAsync() + { + // Arrange + var validator = SdkAntiPatternValidatorTests.CreateValidator(); + var uri = DocumentUri.From("file:///test.cs"); + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, string.Empty, sdkIndex: null, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.AreEqual(0, diagnostics.Count, message: "Empty document should produce no diagnostics."); + } + + [TestMethod] + public async Task ValidateAsync_NullSdkIndex_SkipsSdkDependentChecksAsync() + { + // Arrange + var validator = SdkAntiPatternValidatorTests.CreateValidator(); + var uri = DocumentUri.From("file:///test.cs"); + string code = """ + using System; + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConnectorOperationAttribute : Attribute + { + public string OperationName { get; set; } = ""; + } + public class Test + { + [ConnectorOperation(OperationName = "NonexistentOp")] + public void MyMethod() { } + } + """; + + // Act + IReadOnlyList diagnostics = await validator + .ValidateAsync(uri, code, sdkIndex: null, CancellationToken.None) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsFalse( + diagnostics.Any(diagnostic => + string.Equals(diagnostic.Code?.String, DiagnosticCodes.ConnectorOperationValueUnknown, StringComparison.Ordinal)), + message: "Should not emit CSDK401 when SdkIndex is null."); + } +} diff --git a/Server/Diagnostics/DiagnosticCodes.cs b/Server/Diagnostics/DiagnosticCodes.cs index 5441836..6418a32 100644 --- a/Server/Diagnostics/DiagnosticCodes.cs +++ b/Server/Diagnostics/DiagnosticCodes.cs @@ -104,4 +104,25 @@ internal static class DiagnosticCodes /// Info-level diagnostic when a C# file contains no reference to the SDK namespace. public const string NoSdkUsageDetected = "CSDK400"; + + /// [ConnectorOperation] attribute value doesn't match any known operation in the SDK index. + public const string ConnectorOperationValueUnknown = "CSDK401"; + + /// Input type used where output type is expected. + public const string WrongPayloadTypeDirection = "CSDK402"; + + /// Catching ConnectorException without checking StatusCode property. + public const string ConnectorExceptionWithoutStatusCode = "CSDK403"; + + /// Async connector method called without await keyword. + public const string AsyncConnectorCallWithoutAwait = "CSDK404"; + + /// CancellationToken available in scope but not passed to connector API call. + public const string CancellationTokenNotPassed = "CSDK405"; + + /// Connector response property accessed without null check. + public const string ResponseWithoutNullCheck = "CSDK406"; + + /// Binary content operation result not disposed with using statement. + public const string BinaryContentWithoutUsing = "CSDK407"; } diff --git a/Server/Diagnostics/Validators/SdkAntiPatternValidator.cs b/Server/Diagnostics/Validators/SdkAntiPatternValidator.cs new file mode 100644 index 0000000..d4889b3 --- /dev/null +++ b/Server/Diagnostics/Validators/SdkAntiPatternValidator.cs @@ -0,0 +1,651 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +using OmniSharp.Extensions.LanguageServer.Protocol; + +using LspDiagnostic = OmniSharp.Extensions.LanguageServer.Protocol.Models.Diagnostic; +using LspDiagnosticSeverity = OmniSharp.Extensions.LanguageServer.Protocol.Models.DiagnosticSeverity; + +namespace SdkLspServer.Diagnostics.Validators; + +/// +/// Detects common anti-patterns in SDK usage code. +/// Emits diagnostics CSDK401–CSDK405. +/// +/// CSDK401 — [ConnectorOperation] attribute value doesn't match any known operation. +/// CSDK402 — *Input type used where *Output is expected. +/// CSDK403 — Catching ConnectorException without checking StatusCode. +/// CSDK404 — Async connector method called without await. +/// CSDK405 — CancellationToken available but not passed to connector API call. +/// +/// +internal sealed class SdkAntiPatternValidator : IDiagnosticValidator +{ + private readonly Services.CompilationService compilationService; + + /// + /// Initializes a new instance of the class. + /// + /// The compilation service for semantic analysis. + public SdkAntiPatternValidator(Services.CompilationService compilationService) + { + this.compilationService = compilationService ?? throw new ArgumentNullException(nameof(compilationService)); + } + + /// + public async Task> ValidateAsync( + DocumentUri documentUri, + string documentText, + SdkIndex? sdkIndex, + CancellationToken cancellationToken) + { + var diagnostics = new List(); + + if (string.IsNullOrWhiteSpace(documentText)) + { + return diagnostics; + } + + SyntaxTree tree = CSharpSyntaxTree.ParseText(documentText, cancellationToken: cancellationToken); + CompilationUnitSyntax root = tree.GetCompilationUnitRoot(cancellationToken); + SourceText sourceText = await tree + .GetTextAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + // Syntax-only checks (no compilation needed) + SdkAntiPatternValidator.CheckConnectorOperationValues(root, sourceText, sdkIndex, cancellationToken, diagnostics); + SdkAntiPatternValidator.CheckPayloadTypeDirection(root, sourceText, sdkIndex, cancellationToken, diagnostics); + SdkAntiPatternValidator.CheckConnectorExceptionHandling(root, sourceText, cancellationToken, diagnostics); + + // Semantic checks (require compilation) + string? filePath = string.Equals(documentUri.Scheme, "file", StringComparison.OrdinalIgnoreCase) + ? documentUri.GetFileSystemPath() + : null; + + (_, SemanticModel semanticModel) = this.compilationService + .GetCompilation(documentUri.ToUri(), tree, filePath); + + SdkAntiPatternValidator.CheckAsyncWithoutAwait(root, sourceText, semanticModel, cancellationToken, diagnostics); + SdkAntiPatternValidator.CheckCancellationTokenNotPassed(root, sourceText, semanticModel, cancellationToken, diagnostics); + + return diagnostics; + } + + /// + /// CSDK401: Checks [ConnectorOperation] attribute operation values against + /// known operations in the SDK index. When ConnectorName is present, + /// validation is deferred to (CSDK009). + /// This check only fires when ConnectorName is absent, validating the + /// operation name against all known operations across all connectors. + /// Also handles positional arguments with precise diagnostic range placement. + /// + private static void CheckConnectorOperationValues( + CompilationUnitSyntax root, + SourceText sourceText, + SdkIndex? sdkIndex, + CancellationToken cancellationToken, + List diagnostics) + { + if (sdkIndex is null) + { + return; + } + + foreach (MethodDeclarationSyntax method in root.DescendantNodes().OfType()) + { + foreach (AttributeListSyntax attributeList in method.AttributeLists) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (AttributeSyntax attribute in attributeList.Attributes) + { + string attributeName = attribute.Name.ToString(); + string identifier = ValidatorHelpers.ExtractRightmostIdentifier(attributeName); + + if (!string.Equals(identifier, "ConnectorOperation", StringComparison.Ordinal) && + !string.Equals(identifier, "ConnectorOperationAttribute", StringComparison.Ordinal)) + { + continue; + } + + (string? operationName, AttributeArgumentSyntax? operationArgument) = + SdkAntiPatternValidator.GetOperationNameAndArgumentFromAttribute(attribute); + + if (operationName is null) + { + continue; + } + + // When ConnectorName is present, AttributeValidator already + // validates OperationName (CSDK009). Skip to avoid duplicates. + string? connectorName = SdkAntiPatternValidator.GetConnectorNameFromAttribute(attribute); + + if (connectorName is not null) + { + continue; + } + + bool found = sdkIndex.GetAllTriggerOperations().Any(operation => + string.Equals(operation.Value, operationName, StringComparison.OrdinalIgnoreCase) || + string.Equals(operation.FieldName, operationName, StringComparison.OrdinalIgnoreCase)); + + if (found) + { + continue; + } + + string message = $"Operation '{operationName}' does not match any known connector operation in the SDK index."; + + var range = operationArgument is not null + ? ValidatorHelpers.GetArgumentValueRange(operationArgument, sourceText) + : ValidatorHelpers.GetAttributeNameRange(attribute, sourceText); + + diagnostics.Add(ValidatorHelpers.CreateDiagnostic( + range, + LspDiagnosticSeverity.Warning, + DiagnosticCodes.ConnectorOperationValueUnknown, + message)); + } + } + } + } + + /// + /// CSDK402: Detects input/output type direction mismatches. + /// Warns when an *Input type is used to receive an await result + /// (which typically returns an output type), if a corresponding *Output + /// type exists in the SDK index. + /// + private static void CheckPayloadTypeDirection( + CompilationUnitSyntax root, + SourceText sourceText, + SdkIndex? sdkIndex, + CancellationToken cancellationToken, + List diagnostics) + { + if (sdkIndex is null) + { + return; + } + + foreach (LocalDeclarationStatementSyntax localDecl in root.DescendantNodes().OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + TypeSyntax typeSyntax = localDecl.Declaration.Type; + + // Skip 'var' declarations — can't determine type name from syntax alone. + if (typeSyntax is IdentifierNameSyntax identifierName && + string.Equals(identifierName.Identifier.Text, "var", StringComparison.Ordinal)) + { + continue; + } + + // Unwrap nullable, qualified, and alias-qualified type syntax nodes + // to extract the simple type name (handles T?, Ns.T, global::T). + string simpleTypeName = SdkAntiPatternValidator.GetSimpleTypeName(typeSyntax); + + if (!simpleTypeName.EndsWith("Input", StringComparison.Ordinal)) + { + continue; + } + + foreach (VariableDeclaratorSyntax variable in localDecl.Declaration.Variables) + { + if (variable.Initializer?.Value is AwaitExpressionSyntax) + { + string suggestedType = simpleTypeName.Substring(0, simpleTypeName.Length - "Input".Length) + "Output"; + + if (sdkIndex.TypeNameLookup.Contains(suggestedType)) + { + diagnostics.Add(ValidatorHelpers.CreateDiagnostic( + ValidatorHelpers.ToLspRange(typeSyntax.Span, sourceText), + LspDiagnosticSeverity.Information, + DiagnosticCodes.WrongPayloadTypeDirection, + $"Type '{simpleTypeName}' appears to be an input type but is used to receive an async result. Did you mean '{suggestedType}'?")); + } + + // One diagnostic per declaration is sufficient — the type span + // is shared across all variables in the declaration. + break; + } + } + } + } + + /// + /// CSDK403: Detects catch (ConnectorException) blocks that do not reference + /// StatusCode on the exception variable. + /// + private static void CheckConnectorExceptionHandling( + CompilationUnitSyntax root, + SourceText sourceText, + CancellationToken cancellationToken, + List diagnostics) + { + foreach (CatchClauseSyntax catchClause in root.DescendantNodes().OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (catchClause.Declaration is null) + { + continue; + } + + string typeName = catchClause.Declaration.Type.ToString(); + + // Extract the simple type name using the shared helper that handles + // qualified names (Ns.Type), alias-qualified (global::Type), and + // double-colon forms. + string simpleTypeName = ValidatorHelpers.ExtractRightmostIdentifier(typeName); + + if (!string.Equals(simpleTypeName, "ConnectorException", StringComparison.Ordinal)) + { + continue; + } + + string exceptionVariableName = catchClause.Declaration.Identifier.ValueText; + + if (string.IsNullOrEmpty(exceptionVariableName)) + { + continue; + } + + // Check both regular member access (ex.StatusCode) and conditional + // access (ex?.StatusCode) so that null-safe patterns are recognized. + // Also check the catch filter expression (catch ... when (ex.StatusCode == ...)). + bool referencesStatusCode = SdkAntiPatternValidator.BlockReferencesStatusCode( + catchClause.Block, exceptionVariableName); + + if (!referencesStatusCode && catchClause.Filter?.FilterExpression is not null) + { + referencesStatusCode = SdkAntiPatternValidator.ExpressionReferencesStatusCode( + catchClause.Filter.FilterExpression, exceptionVariableName); + } + + if (!referencesStatusCode) + { + diagnostics.Add(ValidatorHelpers.CreateDiagnostic( + ValidatorHelpers.ToLspRange(catchClause.Declaration.Span, sourceText), + LspDiagnosticSeverity.Warning, + DiagnosticCodes.ConnectorExceptionWithoutStatusCode, + $"Catching ConnectorException without checking StatusCode. Consider inspecting '{exceptionVariableName}.StatusCode' for error-specific handling.")); + } + } + } + + /// + /// CSDK404: Detects async connector methods called without await. + /// Only catches fire-and-forget expression statements (not assignments to variables). + /// Uses semantic analysis to verify the method returns Task or ValueTask. + /// + private static void CheckAsyncWithoutAwait( + CompilationUnitSyntax root, + SourceText sourceText, + SemanticModel semanticModel, + CancellationToken cancellationToken, + List diagnostics) + { + foreach (ExpressionStatementSyntax expressionStatement in root.DescendantNodes().OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + + // If the expression is an await, the invocation is properly awaited. + if (expressionStatement.Expression is AwaitExpressionSyntax) + { + continue; + } + + if (expressionStatement.Expression is not InvocationExpressionSyntax invocation) + { + continue; + } + + // Unwrap chained invocations like + // client.SendEmailAsync(...).ConfigureAwait(continueOnCapturedContext: false) + // to find the underlying connector SDK method call. + InvocationExpressionSyntax connectorInvocation = SdkAntiPatternValidator.UnwrapChainedInvocation( + invocation); + + IMethodSymbol? methodSymbol = SdkAntiPatternValidator.ResolveMethodSymbol( + connectorInvocation, semanticModel, cancellationToken); + + if (methodSymbol is null) + { + continue; + } + + if (!SdkAntiPatternValidator.IsConnectorSdkMethod(methodSymbol, semanticModel)) + { + continue; + } + + string returnTypeName = methodSymbol.ReturnType.Name; + + if (!string.Equals(returnTypeName, "Task", StringComparison.Ordinal) && + !string.Equals(returnTypeName, "ValueTask", StringComparison.Ordinal)) + { + continue; + } + + diagnostics.Add(ValidatorHelpers.CreateDiagnostic( + ValidatorHelpers.ToLspRange(invocation.Span, sourceText), + LspDiagnosticSeverity.Warning, + DiagnosticCodes.AsyncConnectorCallWithoutAwait, + $"Async connector method '{methodSymbol.Name}' called without 'await'. The result will be discarded and errors may go unobserved.")); + } + } + + /// + /// CSDK405: Detects connector API calls within methods that have a + /// CancellationToken parameter, where the token is not forwarded. + /// + private static void CheckCancellationTokenNotPassed( + CompilationUnitSyntax root, + SourceText sourceText, + SemanticModel semanticModel, + CancellationToken cancellationToken, + List diagnostics) + { + foreach (MethodDeclarationSyntax method in root.DescendantNodes().OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Find CancellationToken parameters using the semantic model for + // reliable resolution (handles aliases, global:: qualification, etc.). + string? cancellationTokenParamName = SdkAntiPatternValidator.FindCancellationTokenParameterName( + method, semanticModel, cancellationToken); + + if (cancellationTokenParamName is null) + { + continue; + } + + // Collect invocations from body or expression body. + IEnumerable bodyNodes = method.Body?.DescendantNodes() + ?? method.ExpressionBody?.DescendantNodes() + ?? Enumerable.Empty(); + + foreach (InvocationExpressionSyntax invocation in bodyNodes.OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + + IMethodSymbol? methodSymbol = SdkAntiPatternValidator.ResolveMethodSymbol( + invocation, semanticModel, cancellationToken); + + if (methodSymbol is null) + { + continue; + } + + if (!SdkAntiPatternValidator.IsConnectorSdkMethod(methodSymbol, semanticModel)) + { + continue; + } + + // Check if the SDK method has a CancellationToken parameter. + bool methodAcceptsCancellationToken = methodSymbol.Parameters.Any(parameter => + string.Equals(parameter.Type.Name, "CancellationToken", StringComparison.Ordinal) && + string.Equals( + parameter.Type.ContainingNamespace?.ToDisplayString(), + "System.Threading", + StringComparison.Ordinal)); + + if (!methodAcceptsCancellationToken) + { + continue; + } + + // Check if any argument resolves to a CancellationToken type. + // Uses semantic analysis to handle non-identifier expressions + // (e.g., cts.Token, default, CancellationToken.None). + bool passesCancellationToken = invocation.ArgumentList.Arguments.Any(argument => + { + ITypeSymbol? argumentType = semanticModel.GetTypeInfo(argument.Expression, cancellationToken).Type; + return argumentType is not null && + string.Equals(argumentType.Name, "CancellationToken", StringComparison.Ordinal) && + string.Equals( + argumentType.ContainingNamespace?.ToDisplayString(), + "System.Threading", + StringComparison.Ordinal); + }); + + if (!passesCancellationToken) + { + diagnostics.Add(ValidatorHelpers.CreateDiagnostic( + ValidatorHelpers.ToLspRange(invocation.Span, sourceText), + LspDiagnosticSeverity.Warning, + DiagnosticCodes.CancellationTokenNotPassed, + $"Connector method '{methodSymbol.Name}' accepts a CancellationToken but none was passed. Consider forwarding '{cancellationTokenParamName}'.")); + } + } + } + } + + /// + /// Extracts the operation name and corresponding argument syntax from a + /// [ConnectorOperation] attribute, checking the named OperationName + /// argument first, then falling back to the first positional argument. + /// Returns both the value and the argument node for precise diagnostic placement. + /// + private static (string? OperationName, AttributeArgumentSyntax? Argument) GetOperationNameAndArgumentFromAttribute( + AttributeSyntax attribute) + { + AttributeArgumentSyntax? namedArgument = ValidatorHelpers.FindNamedArgument(attribute, "OperationName"); + + if (namedArgument is not null) + { + return (ValidatorHelpers.ExtractStringValue(namedArgument), namedArgument); + } + + // Fall back to first positional argument. + if (attribute.ArgumentList is not null && + attribute.ArgumentList.Arguments.Count > 0) + { + AttributeArgumentSyntax first = attribute.ArgumentList.Arguments[0]; + + if (first.NameEquals is null && first.NameColon is null) + { + return (ValidatorHelpers.ExtractStringValue(first), first); + } + } + + return (null, null); + } + + /// + /// Extracts the ConnectorName value from a [ConnectorOperation] attribute + /// when present as a named argument. + /// + private static string? GetConnectorNameFromAttribute(AttributeSyntax attribute) + { + AttributeArgumentSyntax? connectorNameArgument = ValidatorHelpers.FindNamedArgument(attribute, "ConnectorName"); + + if (connectorNameArgument is null) + { + return null; + } + + return ValidatorHelpers.ExtractStringValue(connectorNameArgument); + } + + /// + /// Unwraps chained invocations to find the innermost invocation expression. + /// For example, client.SendEmailAsync(...).ConfigureAwait(continueOnCapturedContext: false) resolves + /// to the client.SendEmailAsync(...) invocation so the SDK method symbol + /// can be resolved instead of ConfigureAwait. + /// + private static InvocationExpressionSyntax UnwrapChainedInvocation(InvocationExpressionSyntax invocation) + { + InvocationExpressionSyntax current = invocation; + + // Walk down member-access chains: outer.Method() where outer is itself + // an invocation (i.e., inner.SdkMethod().ConfigureAwait(...)). + while (current.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression is InvocationExpressionSyntax innerInvocation) + { + current = innerInvocation; + } + + return current; + } + + /// + /// Extracts the simple (unqualified, non-nullable) type name from a type syntax node. + /// Handles Nullable<T> (T?), qualified names (Ns.T), + /// alias-qualified names (global::T), and generic names (T<U>). + /// + private static string GetSimpleTypeName(TypeSyntax typeSyntax) + { + return typeSyntax switch + { + NullableTypeSyntax nullable => SdkAntiPatternValidator.GetSimpleTypeName(nullable.ElementType), + QualifiedNameSyntax qualified => SdkAntiPatternValidator.GetSimpleTypeName(qualified.Right), + AliasQualifiedNameSyntax aliasQualified => SdkAntiPatternValidator.GetSimpleTypeName(aliasQualified.Name), + GenericNameSyntax generic => generic.Identifier.Text, + IdentifierNameSyntax identifier => identifier.Identifier.Text, + _ => typeSyntax.ToString(), + }; + } + + /// + /// Checks whether a catch block references StatusCode on the exception variable, + /// handling both regular member access (ex.StatusCode) and conditional access + /// (ex?.StatusCode). + /// + private static bool BlockReferencesStatusCode(BlockSyntax block, string exceptionVariableName) + { + foreach (SyntaxNode node in block.DescendantNodes()) + { + // Regular: ex.StatusCode + if (node is MemberAccessExpressionSyntax memberAccess && + string.Equals(memberAccess.Name.Identifier.Text, "StatusCode", StringComparison.Ordinal) && + memberAccess.Expression is IdentifierNameSyntax memberIdentifier && + string.Equals(memberIdentifier.Identifier.Text, exceptionVariableName, StringComparison.Ordinal)) + { + return true; + } + + // Conditional: ex?.StatusCode + if (node is ConditionalAccessExpressionSyntax conditionalAccess && + conditionalAccess.Expression is IdentifierNameSyntax conditionalIdentifier && + string.Equals(conditionalIdentifier.Identifier.Text, exceptionVariableName, StringComparison.Ordinal) && + conditionalAccess.WhenNotNull is MemberBindingExpressionSyntax memberBinding && + string.Equals(memberBinding.Name.Identifier.Text, "StatusCode", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// Checks whether an expression syntax node references StatusCode on the + /// exception variable. Used to scan catch filter expressions + /// (catch (ConnectorException ex) when (ex.StatusCode == ...)). + /// + private static bool ExpressionReferencesStatusCode(ExpressionSyntax expression, string exceptionVariableName) + { + foreach (SyntaxNode node in expression.DescendantNodesAndSelf()) + { + if (node is MemberAccessExpressionSyntax memberAccess && + string.Equals(memberAccess.Name.Identifier.Text, "StatusCode", StringComparison.Ordinal) && + memberAccess.Expression is IdentifierNameSyntax identifier && + string.Equals(identifier.Identifier.Text, exceptionVariableName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// Uses the semantic model to find a CancellationToken parameter in the + /// given method declaration. More reliable than syntax-string checks because it + /// handles aliases, global:: qualification, and fully-qualified type names. + /// + private static string? FindCancellationTokenParameterName( + MethodDeclarationSyntax method, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + IMethodSymbol? methodSymbol = semanticModel.GetDeclaredSymbol(method, cancellationToken); + + if (methodSymbol is null) + { + return null; + } + + foreach (IParameterSymbol parameter in methodSymbol.Parameters) + { + if (string.Equals(parameter.Type.Name, "CancellationToken", StringComparison.Ordinal) && + string.Equals( + parameter.Type.ContainingNamespace?.ToDisplayString(), + "System.Threading", + StringComparison.Ordinal)) + { + return parameter.Name; + } + } + + return null; + } + + /// + /// Resolves the from an invocation expression, + /// falling back to a single candidate symbol when exact resolution fails. + /// + private static IMethodSymbol? ResolveMethodSymbol( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(invocation, cancellationToken); + + if (symbolInfo.Symbol is IMethodSymbol methodSymbol) + { + return methodSymbol; + } + + if (symbolInfo.CandidateSymbols.Length == 1 && + symbolInfo.CandidateSymbols[0] is IMethodSymbol singleCandidate) + { + return singleCandidate; + } + + return null; + } + + /// + /// Determines whether the given method symbol belongs to a connector SDK type. + /// Checks the containing type's namespace and, for metadata references, the assembly name. + /// + private static bool IsConnectorSdkMethod(IMethodSymbol methodSymbol, SemanticModel semanticModel) + { + string? containingNamespace = methodSymbol.ContainingType?.ContainingNamespace?.ToDisplayString(); + + if (containingNamespace is null || + !containingNamespace.StartsWith("Azure.Connectors.Sdk", StringComparison.Ordinal)) + { + return false; + } + + string? containingAssembly = methodSymbol.ContainingAssembly?.Name; + string? compilationAssembly = semanticModel.Compilation.AssemblyName; + + if (containingAssembly is not null && + !string.Equals(containingAssembly, compilationAssembly, StringComparison.Ordinal) && + !containingAssembly.StartsWith("Azure.Connectors.Sdk", StringComparison.Ordinal)) + { + return false; + } + + return true; + } +} diff --git a/Server/Program.cs b/Server/Program.cs index 177b6d8..25c4009 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -332,6 +332,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Register DiagnosticPublisher (resolved after server is built via ILanguageServerFacade) services.AddSingleton(provider =>