diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotNetLanguageSpecificChecksTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotNetLanguageSpecificChecksTests.cs new file mode 100644 index 00000000000..42712c1f704 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotNetLanguageSpecificChecksTests.cs @@ -0,0 +1,513 @@ +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Azure.Sdk.Tools.Cli.Tests.Services.Languages; + +[TestFixture] +internal class DotNetLanguageSpecificChecksTests +{ + private Mock _processHelperMock = null!; + private Mock _gitHelperMock = null!; + private Mock _powerShellHelperMock = null!; + private DotNetLanguageSpecificChecks _languageChecks = null!; + private string _packagePath = null!; + private string _repoRoot = null!; + private const string RequiredDotNetVersion = "9.0.102"; + + [SetUp] + public void SetUp() + { + _processHelperMock = new Mock(); + _gitHelperMock = new Mock(); + _powerShellHelperMock = new Mock(); + + _languageChecks = new DotNetLanguageSpecificChecks( + _processHelperMock.Object, + _powerShellHelperMock.Object, + _gitHelperMock.Object, + NullLogger.Instance); + + _repoRoot = Path.Combine(Path.GetTempPath(), "azure-sdk-for-net"); + _packagePath = Path.Combine(_repoRoot, "sdk", "healthdataaiservices", "Azure.Health.Deidentification"); + } + + private void SetupSuccessfulDotNetVersionCheck() + { + var versionOutput = $"9.0.102 [C:\\Program Files\\dotnet\\sdk]\n{RequiredDotNetVersion} [C:\\Program Files\\dotnet\\sdk]"; + var processResult = new ProcessResult { ExitCode = 0 }; + processResult.AppendStdout(versionOutput); + + _processHelperMock + .Setup(x => x.Run( + It.Is(p => IsDotNetListSdksCommand(p)), + It.IsAny())) + .ReturnsAsync(processResult); + } + + private void SetupGitRepoDiscovery() + { + _gitHelperMock + .Setup(x => x.DiscoverRepoRoot(It.IsAny())) + .Returns(_repoRoot); + } + + [Test] + public async Task TestCheckGeneratedCodeAsyncDotNetNotInstalledReturnsError() + { + var processResult = new ProcessResult { ExitCode = 1 }; + processResult.AppendStderr("dotnet command not found"); + + _processHelperMock + .Setup(x => x.Run( + It.Is(p => IsDotNetListSdksCommand(p)), + It.IsAny())) + .ReturnsAsync(processResult); + + var result = await _languageChecks.CheckGeneratedCode(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.CheckStatusDetails, Does.Contain("dotnet --list-sdks failed")); + }); + } + + [Test] + public async Task TestCheckGeneratedCodeAsyncDotNetVersionTooLowReturnsError() + { + var versionOutput = "6.0.427 [C:\\Program Files\\dotnet\\sdk]\n8.0.404 [C:\\Program Files\\dotnet\\sdk]"; + var processResult = new ProcessResult { ExitCode = 0 }; + processResult.AppendStdout(versionOutput); + + _processHelperMock + .Setup(x => x.Run( + It.Is(p => IsDotNetListSdksCommand(p)), + It.IsAny())) + .ReturnsAsync(processResult); + + var result = await _languageChecks.CheckGeneratedCode(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.ResponseError, Does.Contain(".NET SDK version 8.0.404 is below minimum requirement of 9.0.102")); + }); + } + + [Test] + public async Task TestCheckGeneratedCodeAsyncInvalidPackagePathReturnsError() + { + SetupSuccessfulDotNetVersionCheck(); + var invalidPath = "/tmp/not-in-sdk-folder"; + + var result = await _languageChecks.CheckGeneratedCode(invalidPath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.ResponseError, Does.Contain("Failed to determine service directory")); + }); + } + + [Test] + public async Task TestCheckGeneratedCodeAsyncSuccessReturnsSuccess() + { + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var scriptPath = Path.Combine(_repoRoot, "eng", "scripts", "CodeChecks.ps1"); + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + File.WriteAllText(scriptPath, "# Mock PowerShell script"); + + var processResult = new ProcessResult { ExitCode = 0 }; + processResult.AppendStdout("All checks passed successfully!"); + + _powerShellHelperMock + .Setup(x => x.Run( + It.Is(p => p.ScriptPath != null && + p.ScriptPath.Contains("CodeChecks.ps1")), + It.IsAny())) + .ReturnsAsync(processResult); + + try + { + var result = await _languageChecks.CheckGeneratedCode(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(0)); + Assert.That(result.CheckStatusDetails, Does.Contain("All checks passed successfully")); + }); + } + finally + { + try { File.Delete(scriptPath); Directory.Delete(Path.GetDirectoryName(scriptPath)!, true); } catch { } + } + } + + [TestCase(true)] + [TestCase(false)] + [Test] + public async Task TestCheckGeneratedCodeAsyncSpellCheckFailureReturnsError(bool isGeneratedCode) + { + + var errorMessage = isGeneratedCode ? + "Generated code does not match committed code. Please regenerate and commit." : + "Spell check failed: 'BlobClinet' is misspelled. Did you mean 'BlobClient'?"; + + var expectedDetail = isGeneratedCode ? + "Generated code does not match" : + "Spell check failed"; + + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var scriptPath = Path.Combine(_repoRoot, "eng", "scripts", "CodeChecks.ps1"); + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + File.WriteAllText(scriptPath, "# Mock PowerShell script"); + + _powerShellHelperMock + .Setup(x => x.Run( + It.Is(p => p.ScriptPath != null && + p.ScriptPath.Contains("CodeChecks.ps1")), + It.IsAny())) + .ReturnsAsync(() => + { + var processResult = new ProcessResult { ExitCode = 1 }; + processResult.AppendStdout(errorMessage); + return processResult; + }); + + var result = await _languageChecks.CheckGeneratedCode(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.CheckStatusDetails, Does.Contain(expectedDetail)); + }); + } + + [Test] + public async Task TestCheckAotCompatAsyncDotNetNotInstalledReturnsError() + { + var processResult = new ProcessResult { ExitCode = 1 }; + processResult.AppendStderr("dotnet command not found"); + + _processHelperMock + .Setup(x => x.Run( + It.Is(p => IsDotNetListSdksCommand(p)), + It.IsAny())) + .ReturnsAsync(processResult); + + var result = await _languageChecks.CheckAotCompat(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.CheckStatusDetails, Does.Contain("dotnet --list-sdks failed")); + }); + } + + [Test] + public async Task TestCheckAotCompatAsyncDotNetVersionTooLowReturnsError() + { + var versionOutput = "6.0.427 [C:\\Program Files\\dotnet\\sdk]\n8.0.404 [C:\\Program Files\\dotnet\\sdk]"; + var processResult = new ProcessResult { ExitCode = 0 }; + processResult.AppendStdout(versionOutput); + + _processHelperMock + .Setup(x => x.Run( + It.Is(p => IsDotNetListSdksCommand(p)), + It.IsAny())) + .ReturnsAsync(processResult); + + var result = await _languageChecks.CheckAotCompat(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.ResponseError, Does.Contain(".NET SDK version 8.0.404 is below minimum requirement of 9.0.102")); + }); + } + + [Test] + public async Task TestCheckAotCompatAsyncInvalidPackagePathReturnsError() + { + SetupSuccessfulDotNetVersionCheck(); + var invalidPath = "/tmp/not-in-sdk-folder"; + + var result = await _languageChecks.CheckAotCompat(invalidPath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.ResponseError, Does.Contain("Failed to determine service directory or package name")); + }); + } + + [Test] + public async Task TestCheckAotCompatAsyncSuccessReturnsSuccess() + { + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var scriptPath = Path.Combine(_repoRoot, "eng", "scripts", "compatibility", "Check-AOT-Compatibility.ps1"); + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + File.WriteAllText(scriptPath, "# Mock PowerShell script"); + + var processResult = new ProcessResult { ExitCode = 0 }; + processResult.AppendStdout("AOT compatibility check passed!"); + + _powerShellHelperMock + .Setup(x => x.Run( + It.Is(p => p.ScriptPath != null && + p.ScriptPath.Contains("Check-AOT-Compatibility.ps1")), + It.IsAny())) + .ReturnsAsync(processResult); + + try + { + var result = await _languageChecks.CheckAotCompat(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(0)); + Assert.That(result.CheckStatusDetails, Does.Contain("AOT compatibility check passed")); + }); + } + finally + { + try { File.Delete(scriptPath); Directory.Delete(Path.GetDirectoryName(scriptPath)!, true); } catch { } + } + } + + [Test] + public async Task TestCheckAotCompatAsyncAotWarningsReturnsError() + { + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var scriptPath = Path.Combine(_repoRoot, "eng", "scripts", "compatibility", "Check-AOT-Compatibility.ps1"); + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + File.WriteAllText(scriptPath, "# Mock PowerShell script"); + + var errorMessage = "ILLink : Trim analysis warning IL2026: Azure.Storage.Blobs.BlobClient.Upload: " + + "Using member 'System.Reflection.Assembly.GetTypes()' which has 'RequiresUnreferencedCodeAttribute' " + + "can break functionality when trimming application code."; + + var processResult = new ProcessResult { ExitCode = 1 }; + processResult.AppendStdout(errorMessage); + + _powerShellHelperMock + .Setup(x => x.Run( + It.Is(p => p.ScriptPath != null && + p.ScriptPath.Contains("Check-AOT-Compatibility.ps1")), + It.IsAny())) + .ReturnsAsync(processResult); + + try + { + var result = await _languageChecks.CheckAotCompat(_packagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.CheckStatusDetails, Does.Contain("Trim analysis warning")); + Assert.That(result.CheckStatusDetails, Does.Contain("RequiresUnreferencedCodeAttribute")); + }); + } + finally + { + try { File.Delete(scriptPath); Directory.Delete(Path.GetDirectoryName(scriptPath)!, true); } catch { } + } + } + + [Test] + public async Task TestCheckAotCompatAsyncWithOptOutReturnsSkipped() + { + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var testPackagePath = Path.Combine(_repoRoot, "sdk", "testservice", "Azure.TestService"); + Directory.CreateDirectory(testPackagePath); + + var csprojPath = Path.Combine(testPackagePath, "Azure.TestService.csproj"); + var csprojContent = @" + + net6.0 + true + +"; + File.WriteAllText(csprojPath, csprojContent); + + try + { + var result = await _languageChecks.CheckAotCompat(testPackagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(0)); + Assert.That(result.CheckStatusDetails, Does.Contain("AOT compatibility check skipped")); + Assert.That(result.CheckStatusDetails, Does.Contain("AotCompatOptOut is set to true")); + }); + } + finally + { + try { Directory.Delete(testPackagePath, true); } catch { } + } + } + + [Test] + public async Task TestCheckAotCompatAsyncWithoutOptOutRunsCheck() + { + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var testPackagePath = Path.Combine(_repoRoot, "sdk", "testservice", "Azure.TestService"); + Directory.CreateDirectory(testPackagePath); + + var csprojPath = Path.Combine(testPackagePath, "Azure.TestService.csproj"); + var csprojContent = @" + + net6.0 + +"; + File.WriteAllText(csprojPath, csprojContent); + + var scriptPath = Path.Combine(_repoRoot, "eng", "scripts", "compatibility", "Check-AOT-Compatibility.ps1"); + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + File.WriteAllText(scriptPath, "# Mock PowerShell script"); + + var processResult = new ProcessResult { ExitCode = 0 }; + processResult.AppendStdout("AOT compatibility check passed!"); + + _powerShellHelperMock + .Setup(x => x.Run( + It.Is(p => p.ScriptPath != null && + p.ScriptPath.Contains("Check-AOT-Compatibility.ps1")), + It.IsAny())) + .ReturnsAsync(processResult); + + try + { + var result = await _languageChecks.CheckAotCompat(testPackagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(0)); + Assert.That(result.CheckStatusDetails, Does.Contain("AOT compatibility check passed")); + Assert.That(result.CheckStatusDetails, Does.Not.Contain("skipped")); + }); + } + finally + { + try + { + Directory.Delete(testPackagePath, true); + File.Delete(scriptPath); + Directory.Delete(Path.GetDirectoryName(scriptPath)!, true); + } + catch { } + } + } + + [Test] + public async Task TestCheckAotCompatAsyncOptOutCaseInsensitive() + { + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var testPackagePath = Path.Combine(_repoRoot, "sdk", "testservice", "Azure.TestService"); + Directory.CreateDirectory(testPackagePath); + + var csprojPath = Path.Combine(testPackagePath, "Azure.TestService.csproj"); + var csprojContent = @" + + net6.0 + TRUE + +"; + File.WriteAllText(csprojPath, csprojContent); + + try + { + var result = await _languageChecks.CheckAotCompat(testPackagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(0)); + Assert.That(result.CheckStatusDetails, Does.Contain("AOT compatibility check skipped")); + Assert.That(result.CheckStatusDetails, Does.Contain("AotCompatOptOut is set to true")); + }); + } + finally + { + try { Directory.Delete(testPackagePath, true); } catch { } + } + } + + [Test] + public async Task TestCheckAotCompatAsyncNoCsprojFileRunsCheck() + { + SetupSuccessfulDotNetVersionCheck(); + SetupGitRepoDiscovery(); + + var testPackagePath = Path.Combine(_repoRoot, "sdk", "testservice", "Azure.TestService"); + Directory.CreateDirectory(testPackagePath); + + var scriptPath = Path.Combine(_repoRoot, "eng", "scripts", "compatibility", "Check-AOT-Compatibility.ps1"); + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + File.WriteAllText(scriptPath, "# Mock PowerShell script"); + + var processResult = new ProcessResult { ExitCode = 0 }; + processResult.AppendStdout("AOT compatibility check passed!"); + + _powerShellHelperMock + .Setup(x => x.Run( + It.Is(p => p.ScriptPath != null && + p.ScriptPath.Contains("Check-AOT-Compatibility.ps1")), + It.IsAny())) + .ReturnsAsync(processResult); + + try + { + var result = await _languageChecks.CheckAotCompat(testPackagePath, ct: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(0)); + Assert.That(result.CheckStatusDetails, Does.Contain("AOT compatibility check passed")); + Assert.That(result.CheckStatusDetails, Does.Not.Contain("skipped")); + }); + + _powerShellHelperMock.Verify(x => x.Run( + It.Is(p => p.ScriptPath != null && + p.ScriptPath.Contains("Check-AOT-Compatibility.ps1")), + It.IsAny()), Times.Once); + } + finally + { + try + { + Directory.Delete(testPackagePath, true); + File.Delete(scriptPath); + Directory.Delete(Path.GetDirectoryName(scriptPath)!, true); + } + catch { } + } + } + + #region Helper Methods for Cross-Platform Command Validation + + /// + /// Checks if the ProcessOptions represents a dotnet --list-sdks command. + /// Handles both Unix (dotnet --list-sdks) and Windows (cmd.exe /C dotnet --list-sdks) patterns. + /// + private static bool IsDotNetListSdksCommand(ProcessOptions options) => + (options.Command == "dotnet" && options.Args.Contains("--list-sdks")) || + (options.Command == "cmd.exe" && options.Args.Contains("dotnet") && options.Args.Contains("--list-sdks")); + + #endregion +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/PackageCheckType.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/PackageCheckType.cs index 956569c1469..51c140e97db 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/PackageCheckType.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/PackageCheckType.cs @@ -45,6 +45,16 @@ public enum PackageCheckType /// Format, + /// + /// .NET validation for AOT compatibility. + /// + CheckAotCompat, + + /// + /// .NET validation for generated code. + /// + GeneratedCodeChecks, + /// /// Validate samples /// diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotNetLanguageSpecificChecks.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotNetLanguageSpecificChecks.cs index d799ab3898c..b0e24adeafd 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotNetLanguageSpecificChecks.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotNetLanguageSpecificChecks.cs @@ -1,7 +1,5 @@ -using System.Runtime.InteropServices; -using Azure.Sdk.Tools.Cli.Models; using Azure.Sdk.Tools.Cli.Helpers; -using Microsoft.Extensions.Logging; +using Azure.Sdk.Tools.Cli.Models; namespace Azure.Sdk.Tools.Cli.Services; @@ -12,19 +10,246 @@ namespace Azure.Sdk.Tools.Cli.Services; public class DotNetLanguageSpecificChecks : ILanguageSpecificChecks { private readonly IProcessHelper _processHelper; - private readonly INpxHelper _npxHelper; private readonly IGitHelper _gitHelper; + private readonly IPowershellHelper _powershellHelper; private readonly ILogger _logger; + private const string DotNetCommand = "dotnet"; + private const string RequiredDotNetVersion = "9.0.102"; // TODO - centralize this as part of env setup tool + private static readonly TimeSpan CodeChecksTimeout = TimeSpan.FromMinutes(6); + private static readonly TimeSpan AotCompatTimeout = TimeSpan.FromMinutes(5); public DotNetLanguageSpecificChecks( IProcessHelper processHelper, - INpxHelper npxHelper, + IPowershellHelper powershellHelper, IGitHelper gitHelper, ILogger logger) { _processHelper = processHelper; - _npxHelper = npxHelper; + _powershellHelper = powershellHelper; _gitHelper = gitHelper; _logger = logger; } + + public async Task CheckGeneratedCode(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default) + { + try + { + _logger.LogInformation("Starting generated code checks for .NET project at: {PackagePath}", packagePath); + + var dotnetVersionValidation = await VerifyDotnetVersion(); + if (dotnetVersionValidation.ExitCode != 0) + { + _logger.LogError("Dotnet version validation failed for generated code checks"); + return dotnetVersionValidation; + } + + var serviceDirectory = GetServiceDirectoryFromPath(packagePath); + if (serviceDirectory == null) + { + _logger.LogError("Failed to determine service directory from package path: {PackagePath}", packagePath); + return new CLICheckResponse(1, "", "Failed to determine service directory from the provided package path."); + } + + var repoRoot = _gitHelper.DiscoverRepoRoot(packagePath); + var scriptPath = Path.Combine(repoRoot, "eng", "scripts", "CodeChecks.ps1"); + if (!File.Exists(scriptPath)) + { + _logger.LogError("Code checks script not found at: {ScriptPath}", scriptPath); + return new CLICheckResponse(1, "", $"Code checks script not found at: {scriptPath}"); + } + + var args = new[] { scriptPath, "-ServiceDirectory", serviceDirectory, "-SpellCheckPublicApiSurface" }; + var options = new PowershellOptions(scriptPath, args, workingDirectory: repoRoot, timeout: CodeChecksTimeout); + var result = await _powershellHelper.Run(options, ct); + + if (result.ExitCode == 0) + { + _logger.LogInformation("Generated code checks completed successfully"); + return new CLICheckResponse(result.ExitCode, result.Output); + } + else + { + _logger.LogWarning("Generated code checks for package at {PackagePath} failed with exit code {ExitCode}", packagePath, result.ExitCode); + return new CLICheckResponse(result.ExitCode, result.Output, "Generated code checks failed"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running generated code checks at {PackagePath}", packagePath); + return new CLICheckResponse(1, "", $"Error running generated code checks: {ex.Message}"); + } + } + + public async Task CheckAotCompat(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default) + { + try + { + _logger.LogInformation("Starting AOT compatibility check for .NET project at: {PackagePath}", packagePath); + + var dotnetVersionValidation = await VerifyDotnetVersion(); + if (dotnetVersionValidation.ExitCode != 0) + { + _logger.LogError("Dotnet version validation failed for AOT compatibility check"); + return dotnetVersionValidation; + } + + var serviceDirectory = GetServiceDirectoryFromPath(packagePath); + var packageName = GetPackageNameFromPath(packagePath); + if (serviceDirectory == null || packageName == null) + { + _logger.LogError("Failed to determine service directory or package name from package path: {PackagePath}", packagePath); + return new CLICheckResponse(1, "", "Failed to determine service directory or package name from the provided package path."); + } + + var isAotOptedOut = await CheckAotCompatOptOut(packagePath, packageName, ct); + if (isAotOptedOut) + { + _logger.LogInformation("AOT compatibility check skipped - AotCompatOptOut is set to true in project file"); + return new CLICheckResponse(0, "AOT compatibility check skipped - AotCompatOptOut is set to true in project file"); + } + + var repoRoot = _gitHelper.DiscoverRepoRoot(packagePath); + var scriptPath = Path.Combine(repoRoot, "eng", "scripts", "compatibility", "Check-AOT-Compatibility.ps1"); + if (!File.Exists(scriptPath)) + { + _logger.LogError("AOT compatibility script not found at: {ScriptPath}", scriptPath); + return new CLICheckResponse(1, "", $"AOT compatibility script not found at: {scriptPath}"); + } + + var workingDirectory = Path.Combine(repoRoot, "eng", "scripts", "compatibility"); + var args = new[] { scriptPath, "-ServiceDirectory", serviceDirectory, "-PackageName", packageName }; + var timeout = AotCompatTimeout; + var options = new PowershellOptions(scriptPath, args, workingDirectory: workingDirectory, timeout: timeout); + var result = await _powershellHelper.Run(options, ct); + + if (result.ExitCode == 0) + { + _logger.LogInformation("AOT compatibility check completed successfully"); + return new CLICheckResponse(result.ExitCode, result.Output); + } + else + { + _logger.LogWarning("AOT compatibility check failed with exit code {ExitCode}", result.ExitCode); + return new CLICheckResponse(result.ExitCode, result.Output, "AOT compatibility check failed"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running AOT compatibility check at {PackagePath}", packagePath); + return new CLICheckResponse(1, "", $"Error running AOT compatibility check: {ex.Message}"); + } + } + + private async ValueTask VerifyDotnetVersion() + { + var dotnetSDKCheck = await _processHelper.Run(new ProcessOptions(DotNetCommand, ["--list-sdks"]), CancellationToken.None); + if (dotnetSDKCheck.ExitCode != 0) + { + _logger.LogError(".NET SDK is not installed or not available in PATH"); + return new CLICheckResponse(dotnetSDKCheck.ExitCode, $"dotnet --list-sdks failed with an error: {dotnetSDKCheck.Output}"); + } + + var dotnetVersions = dotnetSDKCheck.Output.Split(Environment.NewLine.ToCharArray(), StringSplitOptions.RemoveEmptyEntries); + var latestVersionNumber = dotnetVersions[dotnetVersions.Length - 1].Split('[')[0].Trim(); + + if (Version.TryParse(latestVersionNumber, out var installedVersion) && + Version.TryParse(RequiredDotNetVersion, out var minimumVersion)) + { + if (installedVersion >= minimumVersion) + { + _logger.LogInformation(".NET SDK version {InstalledVersion} meets minimum requirement of {RequiredVersion}", latestVersionNumber, RequiredDotNetVersion); + return new CLICheckResponse(0, $".NET SDK version {latestVersionNumber} meets minimum requirement of {RequiredDotNetVersion}"); + } + else + { + _logger.LogError(".NET SDK version {InstalledVersion} is below minimum requirement of {RequiredVersion}", latestVersionNumber, RequiredDotNetVersion); + return new CLICheckResponse(1, "", $".NET SDK version {latestVersionNumber} is below minimum requirement of {RequiredDotNetVersion}"); + } + } + else + { + _logger.LogError("Failed to parse .NET SDK version: {VersionString}", latestVersionNumber); + return new CLICheckResponse(1, "", $"Failed to parse .NET SDK version: {latestVersionNumber}"); + } + } + + private string? GetServiceDirectoryFromPath(string packagePath) + { + string? serviceDirectory = null; + var normalizedPath = packagePath.Replace('\\', '/'); + var sdkIndex = normalizedPath.IndexOf("/sdk/", StringComparison.OrdinalIgnoreCase); + + if (sdkIndex >= 0) + { + var pathAfterSdk = normalizedPath.Substring(sdkIndex + 5); // Skip "/sdk/" + var segments = pathAfterSdk.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length > 0) + { + serviceDirectory = segments[0]; + _logger.LogDebug("Extracted service directory: {ServiceDirectory}", serviceDirectory); + } + else + { + _logger.LogDebug("No segments found after /sdk/ in path"); + } + } + else + { + _logger.LogDebug("Path does not contain /sdk/ segment"); + } + + return serviceDirectory; + } + + private string? GetPackageNameFromPath(string packagePath) + { + string? packageName = null; + var normalizedPath = packagePath.Replace('\\', '/'); + var sdkIndex = normalizedPath.IndexOf("/sdk/", StringComparison.OrdinalIgnoreCase); + + if (sdkIndex >= 0) + { + var pathAfterSdk = normalizedPath.Substring(sdkIndex + 5); // Skip "/sdk/" + var segments = pathAfterSdk.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length > 1) + { + packageName = segments[1]; + } + } + return packageName; + } + + private async ValueTask CheckAotCompatOptOut(string packagePath, string packageName, CancellationToken ct) + { + try + { + var csprojFiles = Directory.GetFiles(packagePath, "*.csproj", SearchOption.AllDirectories); + var mainCsprojFile = csprojFiles.FirstOrDefault(f => + Path.GetFileNameWithoutExtension(f).Equals(packageName, StringComparison.OrdinalIgnoreCase)); + var csprojFile = mainCsprojFile ?? csprojFiles.FirstOrDefault(); + + if (csprojFile == null) + { + _logger.LogDebug("No .csproj file found in package path: {PackagePath}", packagePath); + return false; + } + + var projectContent = await File.ReadAllTextAsync(csprojFile, ct); + + var hasAotOptOut = projectContent.Contains("true", StringComparison.OrdinalIgnoreCase); + + if (hasAotOptOut) + { + _logger.LogInformation("Found AotCompatOptOut=true in project file: {CsprojFile}", csprojFile); + return true; + } + + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check AotCompatOptOut in project file for package: {PackageName}", packageName); + return false; + } + } } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/ILanguageSpecificChecks.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/ILanguageSpecificChecks.cs index a06f25e7aa3..939bb29c969 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/ILanguageSpecificChecks.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/ILanguageSpecificChecks.cs @@ -55,7 +55,6 @@ Task FormatCodeAsync(string packagePath, bool fixCheckErrors = return Task.FromResult(new CLICheckResponse(1, "", "Not implemented for this language.")); } - /// /// Validate samples for the specific package. /// /// Path to the package directory @@ -65,7 +64,30 @@ Task ValidateSamplesAsync(string packagePath, bool fixCheckErr { return Task.FromResult(new CLICheckResponse(1, "", "Not implemented for this language.")); } - + + /// + /// Checks AOT compatibility for the specific package using language-specific tools. + /// + /// Path to the package directory + /// Cancellation token + /// Result of the AOT compatibility check + Task CheckAotCompat(string packagePath, bool fixCheckErrors = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(new CLICheckResponse(1, "", "Not implemented for this language.")); + } + + /// + /// Checks generated code for the specific package using language-specific tools. + /// + /// Path to the package directory + /// Cancellation token + /// Result of the generated code check + Task CheckGeneratedCode(string packagePath, bool fixCheckErrors = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(new CLICheckResponse(1, "", "Not implemented for this language.")); + } + + /// /// Gets the SDK package name. /// /// Repository root path @@ -77,4 +99,4 @@ Task GetSDKPackageName(string repo, string packagePath, CancellationToke // Default implementation: use the directory name as the package path return Task.FromResult(Path.GetFileName(packagePath)); } -} \ No newline at end of file +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/LanguageChecks.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/LanguageChecks.cs index 62e2d08c8bd..d9d0ac5d649 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/LanguageChecks.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/LanguageChecks.cs @@ -1,11 +1,13 @@ -using Azure.Sdk.Tools.Cli.Models; -using Azure.Sdk.Tools.Cli.Helpers; +using System.ComponentModel; +using System.Threading; using Azure.Sdk.Tools.Cli.Configuration; -using Azure.Sdk.Tools.Cli.Prompts; -using Azure.Sdk.Tools.Cli.Prompts.Templates; +using Azure.Sdk.Tools.Cli.Helpers; using Azure.Sdk.Tools.Cli.Microagents; using Azure.Sdk.Tools.Cli.Microagents.Tools; -using System.ComponentModel; +using Azure.Sdk.Tools.Cli.Models; +using Azure.Sdk.Tools.Cli.Prompts; +using Azure.Sdk.Tools.Cli.Prompts.Templates; +using ModelContextProtocol.Protocol; namespace Azure.Sdk.Tools.Cli.Services; @@ -77,6 +79,22 @@ public interface ILanguageChecks /// Result of the code formatting operation Task FormatCodeAsync(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default); + /// + /// Checks AOT compatibility for the specific package. + /// + /// Path to the package directory + /// Cancellation token + /// Result of the AOT compatibility check + Task CheckAotCompat(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default); + + /// + /// Checks generated code for the specific package. + /// + /// Path to the package directory + /// Cancellation token + /// Result of the generated code check + Task CheckGeneratedCode(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default); + /// /// Validates samples for the specific package. /// @@ -226,10 +244,41 @@ public virtual async Task ValidateSamplesAsync(string packageP error: "Unsupported package type" ); } - return await languageSpecificCheck.ValidateSamplesAsync(packagePath, fixCheckErrors, ct); } + public virtual async Task CheckAotCompat(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default) + { + var languageSpecificCheck = await _languageSpecificChecks.Resolve(packagePath); + + if (languageSpecificCheck == null) + { + _logger.LogError("No language-specific check handler found for package at {PackagePath}. Supported languages may not include this package type.", packagePath); + return new CLICheckResponse( + exitCode: 1, + checkStatusDetails: $"No language-specific check handler found for package at {packagePath}. Supported languages may not include this package type.", + error: "Unsupported package type" + ); + } + return await languageSpecificCheck.CheckAotCompat(packagePath, fixCheckErrors, ct); + } + + public virtual async Task CheckGeneratedCode(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default) + { + var languageSpecificCheck = await _languageSpecificChecks.Resolve(packagePath); + + if (languageSpecificCheck == null) + { + _logger.LogError("No language-specific check handler found for package at {PackagePath}. Supported languages may not include this package type.", packagePath); + return new CLICheckResponse( + exitCode: 1, + checkStatusDetails: $"No language-specific check handler found for package at {packagePath}. Supported languages may not include this package type.", + error: "Unsupported package type" + ); + } + + return await languageSpecificCheck.CheckGeneratedCode(packagePath, fixCheckErrors, ct); + } /// /// Common changelog validation implementation that works for most Azure SDK languages. diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/PackageCheckTool.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/PackageCheckTool.cs index ea96606c714..cf9982a6842 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/PackageCheckTool.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/PackageCheckTool.cs @@ -96,6 +96,8 @@ public async Task RunPackageCheck(string packagePath, PackageC PackageCheckType.Snippets => await RunSnippetUpdate(packagePath, fixCheckErrors, ct), PackageCheckType.Linting => await RunLintCode(packagePath, fixCheckErrors, ct), PackageCheckType.Format => await RunFormatCode(packagePath, fixCheckErrors, ct), + PackageCheckType.CheckAotCompat => await RunCheckAotCompat(packagePath, fixCheckErrors, ct), + PackageCheckType.GeneratedCodeChecks => await RunCheckGeneratedCode(packagePath, fixCheckErrors, ct), PackageCheckType.Samples => await RunSampleValidation(packagePath, fixCheckErrors, ct), _ => throw new ArgumentOutOfRangeException( nameof(checkType), @@ -181,6 +183,24 @@ private async Task RunAllChecks(string packagePath, bool fixCh failedChecks.Add("Format"); } + // Run AOT compatibility check + var aotCompatResult = await languageChecks.CheckAotCompat(packagePath, fixCheckErrors, ct); + results.Add(aotCompatResult); + if (aotCompatResult.ExitCode != 0) + { + overallSuccess = false; + failedChecks.Add("AOT Compatibility"); + } + + // Run generated code check + var generatedCodeResult = await languageChecks.CheckGeneratedCode(packagePath, fixCheckErrors, ct); + results.Add(generatedCodeResult); + if (generatedCodeResult.ExitCode != 0) + { + overallSuccess = false; + failedChecks.Add("Generated Code"); + } + // Run sample validation var sampleValidationResult = await languageChecks.ValidateSamplesAsync(packagePath, fixCheckErrors, ct); results.Add(sampleValidationResult); @@ -332,6 +352,22 @@ private async Task RunFormatCode(string packagePath, bool fixC return result; } + private async Task RunCheckGeneratedCode(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default) + { + logger.LogInformation("Running generated code checks"); + + var result = await languageChecks.CheckGeneratedCode(packagePath, fixCheckErrors, ct); + return result; + } + + private async Task RunCheckAotCompat(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default) + { + logger.LogInformation("Running AOT compatibility checks"); + + var result = await languageChecks.CheckAotCompat(packagePath, fixCheckErrors, ct); + return result; + } + private async Task RunSampleValidation(string packagePath, bool fixCheckErrors = false, CancellationToken ct = default) { logger.LogInformation("Running sample validation");