diff --git a/Directory.Build.targets b/Directory.Build.targets index 19b6ea6087f..a02bfcaeb81 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -26,6 +26,15 @@ ReferenceOutputAssembly="false" PrivateAssets="None" Condition=" '$(OrleansBuildTimeCodeGen)' == 'true' "/> + diff --git a/Orleans.slnx b/Orleans.slnx index 58a75bb99b7..4a70b6c63ac 100644 --- a/Orleans.slnx +++ b/Orleans.slnx @@ -35,9 +35,11 @@ + + diff --git a/docs/site/src/content/docs/diagnostics/orleans0020.md b/docs/site/src/content/docs/diagnostics/orleans0020.md index bcaf46b5075..df68774d382 100644 --- a/docs/site/src/content/docs/diagnostics/orleans0020.md +++ b/docs/site/src/content/docs/diagnostics/orleans0020.md @@ -23,7 +23,7 @@ The analyzer has no baseline, so it cannot detect RPC identity, signature, versi ## How to fix -Apply **Regenerate OrleansContracts.txt** to create and populate the complete project manifest. Use **Fix all in solution** to create manifests for every affected project, then add the generated files to source control and review the baseline using the [contract compatibility guidance](../grains/grain-versioning/contract-compatibility-analyzer.md#regenerate-the-manifest). +Apply **Regenerate OrleansContracts.txt** to create and populate the complete project manifest. The configured path can be absent; the code fix creates the file and its parent directory. For a large solution, use the `Microsoft.Orleans.ContractTool` tool to regenerate every enabled project through a filtered workspace. Add the generated files to source control and review the baseline using the [contract compatibility guidance](../grains/grain-versioning/contract-compatibility-analyzer.md#regenerate-the-manifest). ## Suppress the diagnostic diff --git a/docs/site/src/content/docs/grains/grain-versioning/contract-compatibility-analyzer.md b/docs/site/src/content/docs/grains/grain-versioning/contract-compatibility-analyzer.md index 4ad39c9adb3..5bc6d6d8860 100644 --- a/docs/site/src/content/docs/grains/grain-versioning/contract-compatibility-analyzer.md +++ b/docs/site/src/content/docs/grains/grain-versioning/contract-compatibility-analyzer.md @@ -17,7 +17,7 @@ The analyzer is **disabled by default**. Enable it explicitly in a project file ``` -Projects which use `Microsoft.Orleans.Sdk`, `Microsoft.Orleans.Client`, or `Microsoft.Orleans.Server` already receive the Orleans analyzers through those packages. A project which references `Microsoft.Orleans.Analyzers` directly can use the same property. +Projects which use `Microsoft.Orleans.Sdk`, `Microsoft.Orleans.Client`, or `Microsoft.Orleans.Server` already receive the Orleans analyzers through those packages. A project which references `Microsoft.Orleans.Analyzers` directly can use the same property. Scope this property to projects which own Orleans contracts. In a large repository, set it in those projects or in a shared props file for their subtree instead of enabling contract analysis at the repository root. To promote every contract diagnostic, configure the standard `Versioning` category: @@ -30,14 +30,14 @@ This also promotes informational diagnostics such as `ORLEANS0020`. Configure `d ## Configure the manifest path -By default, the analyzer looks for `OrleansContracts.txt` beside the project file. The analyzer package automatically adds an existing file at that location as a compiler `AdditionalFile`; no explicit `AdditionalFiles` item is required. +By default, the analyzer tracks `OrleansContracts.txt` beside the project file. During design-time builds used by IDEs and `dotnet format`, the analyzer package registers the configured path as a compiler `AdditionalFile` before the file exists, allowing regeneration to create it. Regular builds register an existing manifest and report `ORLEANS0020` when the configured manifest is absent. No explicit `AdditionalFiles` item or seed file is required. Set `OrleansContractsPath` to use another location or filename: ```xml true - $(MSBuildProjectDirectory)\contracts\rpc-contracts.txt + $(MSBuildProjectDirectory)/contracts/rpc-contracts.txt ``` @@ -58,10 +58,20 @@ Apply **Regenerate OrleansContracts.txt** from `ORLEANS0016`, `ORLEANS0017`, `OR Agents and command-line workflows can regenerate manifests without an IDE: ```dotnetcli -dotnet format PATH_TO_PROJECT_OR_SOLUTION analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024 +dotnet format PATH_TO_PROJECT.csproj analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024 ``` -Run the command from the repository root. Replace `PATH_TO_PROJECT_OR_SOLUTION` with the path to the owning `.csproj` to regenerate one manifest, or a `.sln`/`.slnx` path to regenerate manifests in every affected project. The `--severity info` option includes `ORLEANS0020`, allowing the command to create a missing manifest. +Run the command from the repository root. Replace `PATH_TO_PROJECT.csproj` with the owning project path to regenerate one manifest. The `--severity info` option includes `ORLEANS0020`, allowing the command to create the default manifest or a configured `OrleansContractsPath`, including its parent directory. + +For a large solution, install the contract tool in the repository and use it to regenerate only analyzer-enabled projects. If the repository does not have a tool manifest, create one first: + +```dotnetcli +dotnet new tool-manifest +dotnet tool install Microsoft.Orleans.ContractTool +dotnet tool run orleans-contracts PATH_TO_SOLUTION.slnx +``` + +The tool evaluates the solution to identify enabled C# projects which contain a manifest or Orleans contract declarations, creates a temporary filtered solution, and runs regeneration against that smaller workspace. Commit the tool manifest so every developer and build agent uses the same tool version. Regeneration edits `OrleansContracts.txt` files only. Source `[Alias]`, `[Id]`, `[GrainType]`, and `[GrainInterfaceType]` attributes remain unchanged. @@ -71,7 +81,7 @@ After the command completes: 1. Inspect `git diff -- "*OrleansContracts.txt"` and account for every changed identity, version, and method signature. 2. Preserve all `*RETIRED*` declarations and retained removed-method signatures unless the compatibility break is intentional. -3. Run `dotnet build PATH_TO_PROJECT_OR_SOLUTION` and resolve all Orleans contract diagnostics. `ORLEANS0027` remains until a removed method is restored or its retained signature is explicitly deleted after compatibility review. +3. Run `dotnet build PATH_TO_PROJECT.csproj` and resolve all Orleans contract diagnostics. `ORLEANS0027` remains until a removed method is restored or its retained signature is explicitly deleted after compatibility review. Add the generated file to source control and review its diff before committing. Treat every changed contract line as a potential wire-compatibility change: @@ -89,10 +99,10 @@ Interface methods are indented beneath their interface: ```text # This file is generated by the Orleans contract analyzer. -# To regenerate, run this command from the repository root after replacing -# PATH_TO_PROJECT_OR_SOLUTION with the owning .csproj, .sln, or .slnx path: -# dotnet format PATH_TO_PROJECT_OR_SOLUTION analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024 -# Verify with: dotnet build PATH_TO_PROJECT_OR_SOLUTION +# To regenerate this project from the repository root: +# dotnet format PATH_TO_PROJECT.csproj analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024 +# To regenerate an enabled solution: dotnet tool run orleans-contracts PATH_TO_SOLUTION +# Verify with: dotnet build PATH_TO_PROJECT.csproj # The regeneration command edits this manifest only; it does not change source attributes. # OrleansContracts format: 2 # Method lines use: wire-identity: CLR-signature. diff --git a/docs/site/src/content/docs/resources/nuget-packages.md b/docs/site/src/content/docs/resources/nuget-packages.md index 30dca3704f7..56f8605886b 100644 --- a/docs/site/src/content/docs/resources/nuget-packages.md +++ b/docs/site/src/content/docs/resources/nuget-packages.md @@ -1,7 +1,7 @@ --- title: Orleans NuGet packages description: Choose Orleans packages for hosts, providers, serialization, observability, and testing. -ms.date: 08/21/2026 +ms.date: 08/29/2026 ms.topic: reference --- @@ -21,6 +21,12 @@ Most applications should begin with one of these packages and then add only the For installation guidance, see [`dotnet package add`](https://learn.microsoft.com/dotnet/core/tools/dotnet-package-add) and [NuGet package installation workflows](https://learn.microsoft.com/nuget/consume-packages/overview-and-workflow). +## Development tools + +| Package | Purpose | +| --- | --- | +| `Microsoft.Orleans.ContractTool` | .NET tool which regenerates `OrleansContracts.txt` manifests for every analyzer-enabled project in a project or solution. | + ## Hosting and observability | Package | Purpose | diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 2520dcbd010..8dabba60799 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -10,9 +10,18 @@ - - + + + + + diff --git a/src/Orleans.Analyzers.Contracts/AnalyzerReleases.Shipped.md b/src/Orleans.Analyzers.Contracts/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000000..197e016e3e5 --- /dev/null +++ b/src/Orleans.Analyzers.Contracts/AnalyzerReleases.Shipped.md @@ -0,0 +1 @@ +; This analyzer has no shipped rules yet. diff --git a/src/Orleans.Analyzers.Contracts/AnalyzerReleases.Unshipped.md b/src/Orleans.Analyzers.Contracts/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000000..4e6c8e23665 --- /dev/null +++ b/src/Orleans.Analyzers.Contracts/AnalyzerReleases.Unshipped.md @@ -0,0 +1,17 @@ +; Please do not edit this file manually, it should only be updated through code fix application. + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +ORLEANS0016 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain interface not declared in OrleansContracts.txt +ORLEANS0017 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain interface version mismatch between code and file +ORLEANS0018 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain interface member not declared in OrleansContracts.txt +ORLEANS0019 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Removed interface not marked as *RETIRED* +ORLEANS0020 | Versioning | Info | GrainInterfaceVersionAnalyzer, OrleansContracts.txt file is missing +ORLEANS0021 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Duplicate interface declaration in file +ORLEANS0022 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain class not declared in OrleansContracts.txt +ORLEANS0023 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain class alias mismatch +ORLEANS0024 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Removed grain class not marked as *RETIRED* +ORLEANS0025 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Duplicate grain class declaration in file +ORLEANS0027 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Removed grain interface member remains in OrleansContracts.txt diff --git a/src/Orleans.Analyzers.Contracts/Orleans.Analyzers.Contracts.csproj b/src/Orleans.Analyzers.Contracts/Orleans.Analyzers.Contracts.csproj new file mode 100644 index 00000000000..727e2e15e22 --- /dev/null +++ b/src/Orleans.Analyzers.Contracts/Orleans.Analyzers.Contracts.csproj @@ -0,0 +1,90 @@ + + + netstandard2.0 + Orleans.Analyzers.Contracts + Orleans.Analyzers + Microsoft.Orleans.Analyzers + Microsoft Orleans Analyzers + C# Analyzers for Microsoft Orleans. + true + false + true + false + true + $(NoWarn);RS1038 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + + + + + + + diff --git a/src/Orleans.Analyzers/AnalyzerReleases.Unshipped.md b/src/Orleans.Analyzers/AnalyzerReleases.Unshipped.md index 826a1e4f6ab..22427299b92 100644 --- a/src/Orleans.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Orleans.Analyzers/AnalyzerReleases.Unshipped.md @@ -6,14 +6,3 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- ORLEANS0026 | Usage | Error | Invalid invokable base type mapping ORLEANS0014 | Usage | Warning | ConfigureAwaitAnalyzer, Grain code should not use ConfigureAwait(false) or ConfigureAwait without ContinueOnCapturedContext -ORLEANS0016 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain interface not declared in OrleansContracts.txt -ORLEANS0017 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain interface version mismatch between code and file -ORLEANS0018 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain interface member not declared in OrleansContracts.txt -ORLEANS0019 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Removed interface not marked as *RETIRED* -ORLEANS0020 | Versioning | Info | GrainInterfaceVersionAnalyzer, OrleansContracts.txt file is missing -ORLEANS0021 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Duplicate interface declaration in file -ORLEANS0022 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain class not declared in OrleansContracts.txt -ORLEANS0023 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Grain class alias mismatch -ORLEANS0024 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Removed grain class not marked as *RETIRED* -ORLEANS0025 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Duplicate grain class declaration in file -ORLEANS0027 | Versioning | Warning | GrainInterfaceVersionAnalyzer, Removed grain interface member remains in OrleansContracts.txt diff --git a/src/Orleans.Analyzers/GrainInterfaceVersionCodeFix.cs b/src/Orleans.Analyzers/GrainInterfaceVersionCodeFix.cs index 00c53b0db94..4b2ddca8347 100644 --- a/src/Orleans.Analyzers/GrainInterfaceVersionCodeFix.cs +++ b/src/Orleans.Analyzers/GrainInterfaceVersionCodeFix.cs @@ -24,18 +24,24 @@ namespace Orleans.Analyzers; public class GrainInterfaceVersionCodeFix : CodeFixProvider { private const string DefaultNewLine = "\n"; + private const string OrleansContractsFileExistsMetadata = + "build_metadata.AdditionalFiles.OrleansContractsFileExists"; + private const string OrleansCoreAbstractionsAssemblyName = "Orleans.Core.Abstractions"; + private const int MaxConcurrentProjectRegenerations = 2; private const string RegenerateCodeActionTitle = "Regenerate OrleansContracts.txt"; private const string RegenerateCodeActionEquivalenceKey = nameof(RegenerateOrleansContractsFileAsync); + private static readonly StringComparison FilePathComparison = + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - private const string RegenerationCommand = - "dotnet format PATH_TO_PROJECT_OR_SOLUTION analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024"; + private const string ProjectRegenerationCommand = + "dotnet format PATH_TO_PROJECT.csproj analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024"; private static readonly string[] GeneratedHeader = [ "# This file is generated by the Orleans contract analyzer.", - "# To regenerate, run this command from the repository root after replacing", - "# PATH_TO_PROJECT_OR_SOLUTION with the owning .csproj, .sln, or .slnx path:", - $"# {RegenerationCommand}", - "# Verify with: dotnet build PATH_TO_PROJECT_OR_SOLUTION", + "# To regenerate this project from the repository root:", + $"# {ProjectRegenerationCommand}", + "# To regenerate an enabled solution: dotnet tool run orleans-contracts PATH_TO_SOLUTION", + "# Verify with: dotnet build PATH_TO_PROJECT.csproj", "# The regeneration command edits this manifest only; it does not change source attributes.", "# OrleansContracts format: 2", "# Method lines use: wire-identity: CLR-signature.", @@ -129,11 +135,21 @@ private static bool HasProperty(Diagnostic diagnostic, string propertyName) private static async Task RegenerateOrleansContractsFileAsync( Project project, CancellationToken cancellationToken) + { + var result = await CreateProjectRegenerationAsync(project, cancellationToken).ConfigureAwait(false); + return result is null + ? project.Solution + : ApplyProjectRegeneration(project.Solution, result); + } + + private static async Task CreateProjectRegenerationAsync( + Project project, + CancellationToken cancellationToken) { var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); if (compilation is null) { - return project.Solution; + return null; } var contractsFile = FindContractsDocument(project); @@ -151,12 +167,21 @@ private static async Task RegenerateOrleansContractsFileAsync( var iAddressableType = compilation.GetTypeByMetadataName(Constants.IAddressibleFullyQualifiedName); var generatedCodeTrees = new Dictionary(); var semanticModels = new Dictionary(); + var hasActiveContracts = false; foreach (var type in GetAllSourceTypes( compilation.Assembly.GlobalNamespace, compilation, generatedCodeTrees, semanticModels) + .Where(type => + type.TypeKind == TypeKind.Interface + && iAddressableType is not null + && !SymbolEqualityComparer.Default.Equals(type, iAddressableType) + && type.AllInterfaces.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate, iAddressableType)) + || type.TypeKind == TypeKind.Class + && !type.IsAbstract + && type.IsGrainClass()) .OrderBy(GetFullyQualifiedName, StringComparer.Ordinal)) { if (type.TypeKind == TypeKind.Interface @@ -164,6 +189,7 @@ private static async Task RegenerateOrleansContractsFileAsync( && !SymbolEqualityComparer.Default.Equals(type, iAddressableType) && type.AllInterfaces.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate, iAddressableType))) { + hasActiveContracts = true; AppendInterface( lines, type, @@ -174,11 +200,18 @@ private static async Task RegenerateOrleansContractsFileAsync( } else if (type.TypeKind == TypeKind.Class && !type.IsAbstract && type.IsGrainClass()) { + hasActiveContracts = true; AppendGrainClass(lines, type, activeClassIdentities, activeConventionClassNames); } } - if (existingText is not null) + var hasExistingManifest = existingText is { Length: > 0 }; + if (!hasActiveContracts && !hasExistingManifest) + { + return null; + } + + if (existingText is { Length: > 0 }) { AppendHistoricalContracts( lines, @@ -193,17 +226,28 @@ private static async Task RegenerateOrleansContractsFileAsync( var content = SortContractEntries(string.Join(newLine, lines), newLine); var newText = SourceText.From(content, Utf8NoBom); - if (contractsFile is not null) + var filePath = GetConfiguredContractsPath(project); + return new ProjectRegeneration( + project.Id, + contractsFile?.Id, + Path.GetFileName(filePath), + filePath, + newText); + } + + private static Solution ApplyProjectRegeneration(Solution solution, ProjectRegeneration result) + { + if (result.DocumentId is { } documentId + && solution.GetAdditionalDocument(documentId) is not null) { - return project.Solution.WithAdditionalDocumentText(contractsFile.Id, newText); + return solution.WithAdditionalDocumentText(documentId, result.Text); } - var filePath = GetConfiguredContractsPath(project); - return project.Solution.AddAdditionalDocument( - DocumentId.CreateNewId(project.Id), - Path.GetFileName(filePath), - newText, - filePath: filePath); + return solution.AddAdditionalDocument( + DocumentId.CreateNewId(result.ProjectId), + result.DocumentName, + result.Text, + filePath: result.FilePath); } private static TextDocument? FindContractsDocument(Project project) @@ -228,7 +272,8 @@ private static async Task RegenerateOrleansContractsFileAsync( } var document = project.AdditionalDocuments.FirstOrDefault(candidate => - string.Equals(candidate.FilePath, additionalFile.Path, StringComparison.OrdinalIgnoreCase)); + candidate.FilePath is { } candidatePath + && PathsEqual(candidatePath, additionalFile.Path)); if (document is not null) { return document; @@ -250,7 +295,7 @@ private static bool PathsEqual(string left, string right) right = Path.GetFullPath(right); } - return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + return string.Equals(left, right, FilePathComparison); } private static string GetConfiguredContractsPath(Project project) @@ -736,28 +781,54 @@ private static async Task RegenerateFixAllAsync( CancellationToken cancellationToken) { var solution = fixAllContext.Solution; - var projectIds = new List(); - if (fixAllContext.Scope == FixAllScope.Solution) + if (fixAllContext.Scope != FixAllScope.Solution) + { + return await RegenerateOrleansContractsFileAsync( + fixAllContext.Project, + cancellationToken).ConfigureAwait(false); + } + + var projects = (await GetProjectsToRegenerateAsync(solution, cancellationToken).ConfigureAwait(false)) + .OrderBy(project => project.FilePath ?? project.Name, StringComparer.Ordinal) + .ToArray(); + if (projects.Length == 0) + { + return solution; + } + + var results = new ProjectRegeneration?[projects.Length]; + var nextProjectIndex = -1; + var workerCount = Math.Min(MaxConcurrentProjectRegenerations, projects.Length); + + async Task RegenerateProjectsAsync() { - foreach (var project in solution.Projects.Where(project => project.Language == LanguageNames.CSharp)) + while (true) { - if (!(await fixAllContext.GetAllDiagnosticsAsync(project).ConfigureAwait(false)).IsEmpty) + var projectIndex = Interlocked.Increment(ref nextProjectIndex); + if (projectIndex >= projects.Length) { - projectIds.Add(project.Id); + return; } + + cancellationToken.ThrowIfCancellationRequested(); + results[projectIndex] = await CreateProjectRegenerationAsync( + projects[projectIndex], + cancellationToken).ConfigureAwait(false); } } - else + + var workers = new Task[workerCount]; + for (var workerIndex = 0; workerIndex < workers.Length; workerIndex++) { - projectIds.Add(fixAllContext.Project.Id); + workers[workerIndex] = RegenerateProjectsAsync(); } - foreach (var projectId in projectIds) + await Task.WhenAll(workers).ConfigureAwait(false); + foreach (var result in results) { - cancellationToken.ThrowIfCancellationRequested(); - if (solution.GetProject(projectId) is { } project) + if (result is not null) { - solution = await RegenerateOrleansContractsFileAsync(project, cancellationToken).ConfigureAwait(false); + solution = ApplyProjectRegeneration(solution, result); } } @@ -765,6 +836,122 @@ private static async Task RegenerateFixAllAsync( } } + private static bool IsContractsAnalyzerEnabled(Project project) + => project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue( + $"build_property.{GrainInterfaceVersionAnalyzer.EnableAnalyzerPropertyName}", + out var value) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + + private static async Task> GetProjectsToRegenerateAsync( + Solution solution, + CancellationToken cancellationToken) + { + var projects = solution.Projects.ToArray(); + var dependents = new Dictionary>(); + var orleansReachableProjects = new HashSet(); + var pendingProjects = new Queue(); + foreach (var project in projects) + { + if (string.Equals( + project.AssemblyName, + OrleansCoreAbstractionsAssemblyName, + StringComparison.OrdinalIgnoreCase) + || project.MetadataReferences.Any(IsOrleansCoreAbstractionsReference)) + { + orleansReachableProjects.Add(project.Id); + pendingProjects.Enqueue(project.Id); + } + + foreach (var projectReference in project.ProjectReferences) + { + if (!dependents.TryGetValue(projectReference.ProjectId, out var projectDependents)) + { + dependents.Add(projectReference.ProjectId, projectDependents = []); + } + + projectDependents.Add(project.Id); + } + } + + while (pendingProjects.Count > 0) + { + var projectId = pendingProjects.Dequeue(); + if (!dependents.TryGetValue(projectId, out var projectDependents)) + { + continue; + } + + foreach (var dependentProjectId in projectDependents) + { + if (orleansReachableProjects.Add(dependentProjectId)) + { + pendingProjects.Enqueue(dependentProjectId); + } + } + } + + var result = ImmutableArray.CreateBuilder(); + foreach (var project in projects) + { + if (project.Language != LanguageNames.CSharp || !IsContractsAnalyzerEnabled(project)) + { + continue; + } + + if (orleansReachableProjects.Contains(project.Id) + || await HasExistingContractsManifestAsync(project, cancellationToken).ConfigureAwait(false)) + { + result.Add(project); + } + } + + return result.ToImmutable(); + } + + private static async Task HasExistingContractsManifestAsync( + Project project, + CancellationToken cancellationToken) + { + var configuredPath = GetConfiguredContractsPath(project); + foreach (var additionalFile in project.AnalyzerOptions.AdditionalFiles) + { + if (PathsEqual(additionalFile.Path, configuredPath) + && project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GetOptions(additionalFile) + .TryGetValue(OrleansContractsFileExistsMetadata, out var value)) + { + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + } + + return FindContractsDocument(project) is { } document + && (await document.GetTextAsync(cancellationToken).ConfigureAwait(false)).Length > 0; + } + + private static bool IsOrleansCoreAbstractionsReference(MetadataReference reference) + => reference.Display is { } display + && string.Equals( + Path.GetFileNameWithoutExtension(display), + OrleansCoreAbstractionsAssemblyName, + StringComparison.OrdinalIgnoreCase); + + private sealed class ProjectRegeneration( + ProjectId projectId, + DocumentId? documentId, + string documentName, + string filePath, + SourceText text) + { + public ProjectId ProjectId { get; } = projectId; + + public DocumentId? DocumentId { get; } = documentId; + + public string DocumentName { get; } = documentName; + + public string FilePath { get; } = filePath; + + public SourceText Text { get; } = text; + } + private static void RegisterAddInterfaceCodeFix(CodeFixContext context, Diagnostic diagnostic) { if (!diagnostic.Properties.TryGetValue(GrainInterfaceVersionAnalyzer.InterfaceNamePropertyKey, out var interfaceName) || diff --git a/src/Orleans.Analyzers/Orleans.Analyzers.csproj b/src/Orleans.Analyzers/Orleans.Analyzers.csproj index d68d00de2e5..7d11c50bccb 100644 --- a/src/Orleans.Analyzers/Orleans.Analyzers.csproj +++ b/src/Orleans.Analyzers/Orleans.Analyzers.csproj @@ -1,12 +1,7 @@ - Microsoft.Orleans.Analyzers - Microsoft Orleans Analyzers - C# Analyzers for Microsoft Orleans. netstandard2.0 - true - false - true + false false true $(NoWarn);RS1038 @@ -18,6 +13,8 @@ + + @@ -27,34 +24,10 @@ - - - - true - %(Identity) - true - - - true - %(Identity) - true - - - true - %(Identity) - true - - - true - %(Identity) - true - - - True diff --git a/src/Orleans.Analyzers/Resources.resx b/src/Orleans.Analyzers/Resources.resx index 488428fac4c..da5c5c12d02 100644 --- a/src/Orleans.Analyzers/Resources.resx +++ b/src/Orleans.Analyzers/Resources.resx @@ -263,7 +263,7 @@ OrleansContracts.txt file is missing - The project contains Orleans contracts but no {0} file. From the repository root, replace PATH_TO_PROJECT_OR_SOLUTION with the owning .csproj, .sln, or .slnx path and run 'dotnet format PATH_TO_PROJECT_OR_SOLUTION analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024'. Review the generated baseline. See https://aka.ms/orleans/OrleansContracts.txt for details. + The project contains Orleans contracts but no {0} file. From the repository root, run 'dotnet format PATH_TO_PROJECT.csproj analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024'. For a solution, run 'dotnet tool run orleans-contracts PATH_TO_SOLUTION'. Review the generated baseline. See https://aka.ms/orleans/OrleansContracts.txt for details. Add an OrleansContracts.txt file to track Orleans contracts for compatibility during rolling upgrades. diff --git a/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.props b/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.props index bb67bff9c88..835a1a5140d 100644 --- a/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.props +++ b/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.props @@ -2,6 +2,7 @@ false $(MSBuildProjectDirectory)\OrleansContracts.txt + $([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '..', 'tools', 'analyzers', 'Orleans.Analyzers.Contracts.dll')) diff --git a/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.targets b/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.targets index e16bb386238..ab59854fef1 100644 --- a/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.targets +++ b/src/Orleans.Analyzers/build/Microsoft.Orleans.Analyzers.targets @@ -1,6 +1,16 @@ - - + + + + + + diff --git a/src/Orleans.ContractTool/Orleans.ContractTool.csproj b/src/Orleans.ContractTool/Orleans.ContractTool.csproj new file mode 100644 index 00000000000..4c5302d10cf --- /dev/null +++ b/src/Orleans.ContractTool/Orleans.ContractTool.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + Microsoft.Orleans.ContractTool + true + orleans-contracts + Regenerates Orleans contract manifests for analyzer-enabled projects. + README.md + false + + + + + + + diff --git a/src/Orleans.ContractTool/Program.cs b/src/Orleans.ContractTool/Program.cs new file mode 100644 index 00000000000..017fb443b1f --- /dev/null +++ b/src/Orleans.ContractTool/Program.cs @@ -0,0 +1,235 @@ +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; + +const string ProjectMarker = "ORLEANS_CONTRACT_PROJECT="; +string[] diagnosticIds = +[ + "ORLEANS0016", + "ORLEANS0017", + "ORLEANS0018", + "ORLEANS0019", + "ORLEANS0020", + "ORLEANS0022", + "ORLEANS0023", + "ORLEANS0024", +]; + +var dryRun = args.Contains("--dry-run", StringComparer.Ordinal); +var paths = args.Where(arg => !string.Equals(arg, "--dry-run", StringComparison.Ordinal)).ToArray(); +var pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; +var contractDeclarationPattern = new Regex( + @"(?:\[\s*(?:global::)?(?:Orleans\.)?(?:GrainType|GrainInterfaceType)\b)|(?:\b(?:interface|class|record)\s+[\w@][^{;]*:\s*[^{;]*(?:\bIGrain\w*\b|\bIAddressable\b|\bGrain(?:<|\b)))", + RegexOptions.Compiled | RegexOptions.CultureInvariant); +if (paths.Length != 1) +{ + Console.Error.WriteLine("Usage: orleans-contracts [--dry-run] "); + return 1; +} + +var inputPath = Path.GetFullPath(paths[0]); +if (!File.Exists(inputPath)) +{ + Console.Error.WriteLine($"Project or solution not found: {inputPath}"); + return 1; +} + +if (string.Equals(Path.GetExtension(inputPath), ".csproj", StringComparison.OrdinalIgnoreCase)) +{ + if (dryRun) + { + Console.WriteLine(inputPath); + return 0; + } + + return await RunDotNetFormatAsync(inputPath, diagnosticIds, Path.GetDirectoryName(inputPath)!); +} + +var extension = Path.GetExtension(inputPath); +if (!string.Equals(extension, ".sln", StringComparison.OrdinalIgnoreCase) + && !string.Equals(extension, ".slnx", StringComparison.OrdinalIgnoreCase)) +{ + Console.Error.WriteLine("Expected a .csproj, .sln, or .slnx path."); + return 1; +} + +var temporaryDirectory = Path.Combine(Path.GetTempPath(), "OrleansContracts", Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(temporaryDirectory); +try +{ + var targetsPath = Path.Combine(temporaryDirectory, "CollectOrleansContractProjects.targets"); + await File.WriteAllTextAsync( + targetsPath, + """ + + + + + + """); + + var discovery = await RunProcessAsync( + "dotnet", + [ + "msbuild", + inputPath, + "-t:_CollectOrleansContractProjects", + "-m", + "-nologo", + "-v:minimal", + $"-p:CustomAfterMicrosoftCommonTargets={targetsPath}", + $"-p:CustomAfterMicrosoftCommonCrossTargetingTargets={targetsPath}", + ], + Path.GetDirectoryName(inputPath)!, + captureOutput: true); + if (discovery.ExitCode != 0) + { + Console.Error.Write(discovery.StandardError); + Console.Error.Write(discovery.StandardOutput); + return discovery.ExitCode; + } + + var projects = discovery.StandardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => line.Contains(ProjectMarker, StringComparison.Ordinal)) + .Select(line => line[(line.IndexOf(ProjectMarker, StringComparison.Ordinal) + ProjectMarker.Length)..].Trim()) + .Select(value => + { + var separatorIndex = value.IndexOf("::", StringComparison.Ordinal); + return separatorIndex < 0 + ? new ContractProject(value, string.Empty) + : new ContractProject(value[..separatorIndex], value[(separatorIndex + 2)..]); + }) + .Where(project => + File.Exists(project.ProjectPath) + && string.Equals(Path.GetExtension(project.ProjectPath), ".csproj", StringComparison.OrdinalIgnoreCase) + && (File.Exists(project.ContractsPath) || ContainsContractDeclarations(project.ProjectPath))) + .DistinctBy(project => project.ProjectPath, pathComparer) + .OrderBy(project => project.ProjectPath, pathComparer) + .ToArray(); + if (projects.Length == 0) + { + Console.WriteLine("No projects have EnableOrleansContractsAnalyzer set to true."); + return 0; + } + + if (dryRun) + { + foreach (var project in projects) + { + Console.WriteLine(project.ProjectPath); + } + + return 0; + } + + var solutionDirectory = Path.GetDirectoryName(inputPath)!; + var filteredSolutionPath = Path.Combine(temporaryDirectory, "OrleansContracts.slnx"); + try + { + var content = new StringBuilder(); + content.AppendLine(""); + foreach (var project in projects) + { + var relativePath = Path.GetRelativePath(temporaryDirectory, project.ProjectPath).Replace('\\', '/'); + content.Append(" "); + } + + content.AppendLine(""); + await File.WriteAllTextAsync(filteredSolutionPath, content.ToString()); + + Console.WriteLine($"Regenerating Orleans contracts in {projects.Length} project(s)."); + return await RunDotNetFormatAsync(filteredSolutionPath, diagnosticIds, solutionDirectory); + } + finally + { + File.Delete(filteredSolutionPath); + } +} +finally +{ + Directory.Delete(temporaryDirectory, recursive: true); +} + +bool ContainsContractDeclarations(string projectPath) +{ + var projectDirectory = Path.GetDirectoryName(projectPath)!; + foreach (var sourcePath in Directory.EnumerateFiles(projectDirectory, "*.cs", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(projectDirectory, sourcePath); + if (relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(segment => segment is "bin" or "obj")) + { + continue; + } + + if (contractDeclarationPattern.IsMatch(File.ReadAllText(sourcePath))) + { + return true; + } + } + + return false; +} + +static async Task RunDotNetFormatAsync( + string path, + string[] diagnosticIds, + string workingDirectory) +{ + var arguments = new List + { + "format", + path, + "analyzers", + "--severity", + "info", + "--diagnostics", + }; + arguments.AddRange(diagnosticIds); + var result = await RunProcessAsync( + "dotnet", + arguments, + workingDirectory, + captureOutput: false); + return result.ExitCode; +} + +static async Task RunProcessAsync( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + bool captureOutput) +{ + var startInfo = new ProcessStartInfo(fileName) + { + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = captureOutput, + RedirectStandardError = captureOutput, + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + if (process is null) + { + throw new InvalidOperationException($"Could not start {fileName}."); + } + + var standardOutput = captureOutput ? process.StandardOutput.ReadToEndAsync() : Task.FromResult(string.Empty); + var standardError = captureOutput ? process.StandardError.ReadToEndAsync() : Task.FromResult(string.Empty); + await process.WaitForExitAsync(); + return new ProcessResult(process.ExitCode, await standardOutput, await standardError); +} + +readonly record struct ProcessResult(int ExitCode, string StandardOutput, string StandardError); + +readonly record struct ContractProject(string ProjectPath, string ContractsPath); diff --git a/src/Orleans.ContractTool/README.md b/src/Orleans.ContractTool/README.md new file mode 100644 index 00000000000..65555bcd36e --- /dev/null +++ b/src/Orleans.ContractTool/README.md @@ -0,0 +1,18 @@ +# Microsoft Orleans contract tool + +The `orleans-contracts` .NET tool regenerates `OrleansContracts.txt` manifests for projects which enable the Orleans contract compatibility analyzer. + +Install it in a repository tool manifest: + +```console +dotnet new tool-manifest +dotnet tool install Microsoft.Orleans.ContractTool +``` + +Regenerate one project or every enabled project in a solution: + +```console +dotnet tool run orleans-contracts PATH_TO_PROJECT_OR_SOLUTION +``` + +For solution input, the tool evaluates the project graph, selects enabled C# projects which contain a manifest or Orleans contract declarations, and runs the analyzer fixes against a temporary filtered solution. diff --git a/src/Orleans.Sdk/Orleans.Sdk.csproj b/src/Orleans.Sdk/Orleans.Sdk.csproj index 28577511822..227e9feda7f 100644 --- a/src/Orleans.Sdk/Orleans.Sdk.csproj +++ b/src/Orleans.Sdk/Orleans.Sdk.csproj @@ -33,7 +33,7 @@ - + diff --git a/test/Orleans.Analyzers.Tests/GrainInterfaceVersionAnalyzerTest.cs b/test/Orleans.Analyzers.Tests/GrainInterfaceVersionAnalyzerTest.cs index 12fee0febd8..99d45e4b19b 100644 --- a/test/Orleans.Analyzers.Tests/GrainInterfaceVersionAnalyzerTest.cs +++ b/test/Orleans.Analyzers.Tests/GrainInterfaceVersionAnalyzerTest.cs @@ -1,6 +1,7 @@ #nullable enable using System.Collections.Immutable; +using System.Diagnostics; using System.Globalization; using System.Reflection; using System.Text; @@ -29,10 +30,10 @@ public class GrainInterfaceVersionAnalyzerTest private const string RegenerateCodeActionEquivalenceKey = "RegenerateOrleansContractsFileAsync"; private const string GeneratedHeader = "# This file is generated by the Orleans contract analyzer.\n" + - "# To regenerate, run this command from the repository root after replacing\n" + - "# PATH_TO_PROJECT_OR_SOLUTION with the owning .csproj, .sln, or .slnx path:\n" + - "# dotnet format PATH_TO_PROJECT_OR_SOLUTION analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024\n" + - "# Verify with: dotnet build PATH_TO_PROJECT_OR_SOLUTION\n" + + "# To regenerate this project from the repository root:\n" + + "# dotnet format PATH_TO_PROJECT.csproj analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024\n" + + "# To regenerate an enabled solution: dotnet tool run orleans-contracts PATH_TO_SOLUTION\n" + + "# Verify with: dotnet build PATH_TO_PROJECT.csproj\n" + "# The regeneration command edits this manifest only; it does not change source attributes.\n" + "# OrleansContracts format: 2\n" + "# Method lines use: wire-identity: CLR-signature.\n" + @@ -929,7 +930,8 @@ public interface IMyGrain : IGrain var diagnostic = Assert.Single(diagnostics, d => d.Id == GrainInterfaceVersionAnalyzer.RuleId0020); Assert.Contains(OrleansContractsFileName, diagnostic.GetMessage()); Assert.True(diagnostic.Location.IsInSource); - Assert.Contains("dotnet format PATH_TO_PROJECT_OR_SOLUTION", diagnostic.GetMessage()); + Assert.Contains("dotnet format PATH_TO_PROJECT.csproj", diagnostic.GetMessage()); + Assert.Contains("dotnet tool run orleans-contracts PATH_TO_SOLUTION", diagnostic.GetMessage()); Assert.Contains("https://aka.ms/orleans/OrleansContracts.txt", diagnostic.GetMessage()); } @@ -1802,29 +1804,59 @@ private static Project CreateProjectWithAdditionalFilesForCodeFix( filePath: contractsDocumentPath); } + solution = AddContractsAnalyzerConfig(solution, projectId, configuredContractsPath); + + return solution.GetProject(projectId)! + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private static Solution AddContractsAnalyzerConfig( + Solution solution, + ProjectId projectId, + string? configuredContractsPath = null, + bool contractsFileExists = false) + { + var analyzerConfigDirectory = configuredContractsPath is null + ? Path.GetTempPath() + : Path.GetDirectoryName(configuredContractsPath); + var analyzerConfigPath = Path.Combine( + string.IsNullOrEmpty(analyzerConfigDirectory) ? Path.GetTempPath() : analyzerConfigDirectory, + projectId.Id.ToString("N"), + ".globalconfig"); + var content = + $"is_global = true{Environment.NewLine}" + + $"build_property.{GrainInterfaceVersionAnalyzer.EnableAnalyzerPropertyName} = true{Environment.NewLine}"; if (configuredContractsPath is not null) { - var analyzerConfigDirectory = Path.GetDirectoryName(configuredContractsPath); - var analyzerConfigPath = string.IsNullOrEmpty(analyzerConfigDirectory) - ? Path.Combine(Path.GetTempPath(), ".globalconfig") - : Path.Combine(analyzerConfigDirectory, ".globalconfig"); - solution = solution.AddAnalyzerConfigDocument( - DocumentId.CreateNewId(projectId, ".globalconfig"), - ".globalconfig", - SourceText.From( - $"is_global = true{Environment.NewLine}" + - $"build_property.OrleansContractsPath = {configuredContractsPath}{Environment.NewLine}"), - filePath: analyzerConfigPath); + content += $"build_property.OrleansContractsPath = {configuredContractsPath}{Environment.NewLine}"; } - return solution.GetProject(projectId)! - .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + if (contractsFileExists) + { + content += + $"{Environment.NewLine}[*.txt]{Environment.NewLine}" + + $"build_metadata.AdditionalFiles.OrleansContractsFileExists = true{Environment.NewLine}"; + } + + return solution.AddAnalyzerConfigDocument( + DocumentId.CreateNewId(projectId, ".globalconfig"), + ".globalconfig", + SourceText.From(content), + filePath: analyzerConfigPath); } #endregion #region Code Fix Tests - Regenerate + [Fact] + public void ContractAnalyzer_IsolatedFromStandardAnalyzers() + { + Assert.NotEqual(typeof(AlwaysInterleaveDiagnosticAnalyzer).Assembly, typeof(GrainInterfaceVersionAnalyzer).Assembly); + Assert.Single(typeof(GrainInterfaceVersionAnalyzer).GetCustomAttributes(typeof(DiagnosticAnalyzerAttribute), inherit: false)); + Assert.Single(typeof(GrainInterfaceVersionCodeFix).GetCustomAttributes(typeof(ExportCodeFixProviderAttribute), inherit: false)); + } + [Fact] public async Task CodeFix_RegenerateMissingFile_CreatesCompleteManifest() { @@ -2429,7 +2461,7 @@ await GetDiagnosticsAsync(source, content), } [Fact] - public async Task FixAll_RegenerateSolution_UpdatesEveryProjectWithDiagnostics() + public async Task FixAll_RegenerateSolution_DoesNotRequestDiagnosticsAgain() { var firstProject = CreateProjectWithAdditionalFilesForCodeFix( "public interface IFirstGrain : IGrain { Task Ping(); }", @@ -2448,13 +2480,9 @@ public async Task FixAll_RegenerateSolution_UpdatesEveryProjectWithDiagnostics() .WithProjectCompilationOptions(secondProjectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) .AddDocument(secondDocumentId, "Second.cs", SourceText.From(secondSource)) .AddAdditionalDocument(secondContractsId, OrleansContractsFileName, SourceText.From("# OrleansContracts.txt\n")); + solution = AddContractsAnalyzerConfig(solution, secondProjectId); firstProject = solution.GetProject(firstProject.Id)!; - var diagnostics = new Dictionary> - { - [firstProject.Id] = new[] { CreateFixAllDiagnostic() }, - [secondProjectId] = new[] { CreateFixAllDiagnostic() } - }; var codeFixer = new GrainInterfaceVersionCodeFix(); var context = new FixAllContext( firstProject.Documents.First(), @@ -2462,7 +2490,7 @@ public async Task FixAll_RegenerateSolution_UpdatesEveryProjectWithDiagnostics() FixAllScope.Solution, RegenerateCodeActionEquivalenceKey, codeFixer.FixableDiagnosticIds, - new TestFixAllDiagnosticProvider(diagnostics), + ThrowingFixAllDiagnosticProvider.Instance, TestContext.Current.CancellationToken); var action = await codeFixer.GetFixAllProvider().GetFixAsync(context); @@ -2478,10 +2506,494 @@ public async Task FixAll_RegenerateSolution_UpdatesEveryProjectWithDiagnostics() Assert.Contains("interface [GrainInterfaceType(\"ISecondGrain\")] ISecondGrain [Version(0)]", secondContent); } - private static Diagnostic CreateFixAllDiagnostic() + [Fact] + public async Task FixAll_RegenerateSolution_SkipsEnabledProjectsWithoutOrleansReferenceOrManifest() + { + var relevantProject = CreateProjectWithAdditionalFilesForCodeFix( + "public interface IRelevantGrain : IGrain { Task Ping(); }", + "# OrleansContracts.txt\n"); + var unrelatedProjectId = ProjectId.CreateNewId("UnrelatedProject"); + var unrelatedDocumentId = DocumentId.CreateNewId(unrelatedProjectId, "Unrelated.cs"); + var nonOrleansReferences = relevantProject.MetadataReferences + .Where(reference => !string.Equals( + Path.GetFileNameWithoutExtension(reference.Display), + "Orleans.Core.Abstractions", + StringComparison.OrdinalIgnoreCase)); + var solution = relevantProject.Solution + .AddProject(unrelatedProjectId, "UnrelatedProject", "UnrelatedProject", LanguageNames.CSharp) + .AddMetadataReferences(unrelatedProjectId, nonOrleansReferences) + .WithProjectCompilationOptions(unrelatedProjectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddDocument(unrelatedDocumentId, "Unrelated.cs", SourceText.From("public sealed class Unrelated;")); + solution = AddContractsAnalyzerConfig(solution, unrelatedProjectId); + + var changedSolution = await ApplySolutionFixAllAsync( + solution.GetProject(relevantProject.Id)!, + ThrowingFixAllDiagnosticProvider.Instance); + + Assert.Empty(changedSolution.GetProject(unrelatedProjectId)!.AdditionalDocuments); + var content = await GetOnlyContractsDocumentTextAsync(changedSolution, relevantProject.Id); + Assert.Contains("interface [GrainInterfaceType(\"IRelevantGrain\")] IRelevantGrain [Version(0)]", content); + } + + [Fact] + public async Task FixAll_RegenerateSolution_SkipsDisabledProjectsBeforeCompilation() + { + var relevantProject = CreateProjectWithAdditionalFilesForCodeFix( + "public interface IRelevantGrain : IGrain { Task Ping(); }", + "# OrleansContracts.txt\n"); + var disabledProjectId = ProjectId.CreateNewId("DisabledProject"); + var disabledDocumentId = DocumentId.CreateNewId(disabledProjectId, "Disabled.cs"); + var solution = relevantProject.Solution + .AddProject(disabledProjectId, "DisabledProject", "DisabledProject", LanguageNames.CSharp) + .AddMetadataReferences(disabledProjectId, relevantProject.MetadataReferences) + .WithProjectCompilationOptions(disabledProjectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddDocument(disabledDocumentId, "Disabled.cs", SourceText.From( + "using Orleans; public interface IDisabledGrain : IGrain { }")); + + var changedSolution = await ApplySolutionFixAllAsync( + solution.GetProject(relevantProject.Id)!, + ThrowingFixAllDiagnosticProvider.Instance); + + Assert.Empty(changedSolution.GetProject(disabledProjectId)!.AdditionalDocuments); + var content = await GetOnlyContractsDocumentTextAsync(changedSolution, relevantProject.Id); + Assert.Contains("interface [GrainInterfaceType(\"IRelevantGrain\")] IRelevantGrain [Version(0)]", content); + } + + [Fact] + public async Task FixAll_RegenerateSolution_DoesNotCreateManifestForProjectWithoutContracts() + { + var relevantProject = CreateProjectWithAdditionalFilesForCodeFix( + "public interface IRelevantGrain : IGrain { Task Ping(); }", + "# OrleansContracts.txt\n"); + var emptyProjectId = ProjectId.CreateNewId("EmptyProject"); + var emptyDocumentId = DocumentId.CreateNewId(emptyProjectId, "Empty.cs"); + var solution = relevantProject.Solution + .AddProject(emptyProjectId, "EmptyProject", "EmptyProject", LanguageNames.CSharp) + .AddMetadataReferences(emptyProjectId, relevantProject.MetadataReferences) + .WithProjectCompilationOptions(emptyProjectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddDocument(emptyDocumentId, "Empty.cs", SourceText.From("public sealed class Empty;")); + solution = AddContractsAnalyzerConfig(solution, emptyProjectId); + + var changedSolution = await ApplySolutionFixAllAsync( + solution.GetProject(relevantProject.Id)!, + ThrowingFixAllDiagnosticProvider.Instance); + + Assert.Empty(changedSolution.GetProject(emptyProjectId)!.AdditionalDocuments); + var content = await GetOnlyContractsDocumentTextAsync(changedSolution, relevantProject.Id); + Assert.Contains("interface [GrainInterfaceType(\"IRelevantGrain\")] IRelevantGrain [Version(0)]", content); + } + + [Fact] + public async Task FixAll_RegenerateSolution_ProcessesTransitiveOrleansProjectReferences() + { + var templateProject = CreateProjectWithAdditionalFilesForCodeFix( + "public sealed class ReferenceTemplate;", + grainInterfacesFileContent: null); + var nonOrleansReferences = templateProject.MetadataReferences + .Where(reference => !string.Equals( + Path.GetFileNameWithoutExtension(reference.Display), + "Orleans.Core.Abstractions", + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + var orleansReference = Assert.Single(templateProject.MetadataReferences, reference => string.Equals( + Path.GetFileNameWithoutExtension(reference.Display), + "Orleans.Core.Abstractions", + StringComparison.OrdinalIgnoreCase)); + var hiddenOrleansReference = MetadataReference.CreateFromImage( + ImmutableArray.Create(File.ReadAllBytes(orleansReference.Display!)), + filePath: "TransitiveDependency.dll"); + var baseProjectId = ProjectId.CreateNewId("BaseProject"); + var middleProjectId = ProjectId.CreateNewId("MiddleProject"); + var contractProjectId = ProjectId.CreateNewId("ContractProject"); + var solution = new AdhocWorkspace().CurrentSolution + .AddProject(baseProjectId, "BaseProject", "BaseProject", LanguageNames.CSharp) + .WithProjectCompilationOptions(baseProjectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReferences(baseProjectId, nonOrleansReferences.Append(orleansReference)) + .AddDocument( + DocumentId.CreateNewId(baseProjectId, "Base.cs"), + "Base.cs", + SourceText.From( + "public sealed class BaseType;")) + .AddProject(middleProjectId, "MiddleProject", "MiddleProject", LanguageNames.CSharp) + .WithProjectCompilationOptions(middleProjectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReferences(middleProjectId, nonOrleansReferences) + .AddProjectReference(middleProjectId, new ProjectReference(baseProjectId)) + .AddDocument( + DocumentId.CreateNewId(middleProjectId, "Middle.cs"), + "Middle.cs", + SourceText.From( + "public sealed class MiddleType;")) + .AddProject(contractProjectId, "ContractProject", "ContractProject", LanguageNames.CSharp) + .WithProjectCompilationOptions(contractProjectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReferences(contractProjectId, nonOrleansReferences.Append(hiddenOrleansReference)) + .AddProjectReference(contractProjectId, new ProjectReference(middleProjectId)) + .AddDocument( + DocumentId.CreateNewId(contractProjectId, "Contract.cs"), + "Contract.cs", + SourceText.From( + "using Orleans; using System.Threading.Tasks; public interface ITransitiveGrain : IGrain { Task Ping(); }")) + .AddAdditionalDocument( + DocumentId.CreateNewId(contractProjectId, OrleansContractsFileName), + OrleansContractsFileName, + SourceText.From("# OrleansContracts.txt\n")); + solution = AddContractsAnalyzerConfig(solution, contractProjectId); + + var changedSolution = await ApplySolutionFixAllAsync( + solution.GetProject(contractProjectId)!, + ThrowingFixAllDiagnosticProvider.Instance); + + var content = await GetOnlyContractsDocumentTextAsync(changedSolution, contractProjectId); + Assert.Contains( + "interface [GrainInterfaceType(\"ITransitiveGrain\")] ITransitiveGrain [Version(0)]", + content); + } + + [Fact] + public async Task FixAll_RegenerateSolution_ProcessesExistingManifestWithoutCurrentOrleansReference() + { + var templateProject = CreateProjectWithAdditionalFilesForCodeFix( + "public sealed class ReferenceTemplate;", + grainInterfacesFileContent: null); + var projectId = ProjectId.CreateNewId("HistoricalProject"); + var contractsPath = Path.Combine( + Path.GetTempPath(), + projectId.Id.ToString("N"), + OrleansContractsFileName); + var contractsDocumentId = DocumentId.CreateNewId(projectId, OrleansContractsFileName); + var solution = new AdhocWorkspace().CurrentSolution + .AddProject(projectId, "HistoricalProject", "HistoricalProject", LanguageNames.CSharp) + .WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReferences( + projectId, + templateProject.MetadataReferences.Where(reference => !string.Equals( + Path.GetFileNameWithoutExtension(reference.Display), + "Orleans.Core.Abstractions", + StringComparison.OrdinalIgnoreCase))) + .AddDocument( + DocumentId.CreateNewId(projectId, "Historical.cs"), + "Historical.cs", + SourceText.From("public sealed class Historical;")) + .AddAdditionalDocument( + contractsDocumentId, + OrleansContractsFileName, + SourceText.From("interface OldGrain [Version(1)]\n"), + filePath: contractsPath); + solution = AddContractsAnalyzerConfig(solution, projectId, contractsPath); + + var changedSolution = await ApplySolutionFixAllAsync( + solution.GetProject(projectId)!, + ThrowingFixAllDiagnosticProvider.Instance); + + var content = await GetOnlyContractsDocumentTextAsync(changedSolution, projectId); + Assert.Contains("*RETIRED* interface OldGrain [Version(1)]", content); + } + + [Fact] + public async Task FixAll_RegenerateSolution_IsDeterministicAcrossProjectOrder() + { + var forward = await RegenerateSolutionAsync(["Alpha", "Beta"]); + var reverse = await RegenerateSolutionAsync(["Beta", "Alpha"]); + + Assert.Equal(forward.Keys.Order(StringComparer.Ordinal), reverse.Keys.Order(StringComparer.Ordinal)); + foreach (var projectName in forward.Keys) + { + Assert.Equal(forward[projectName], reverse[projectName]); + } + + static async Task> RegenerateSolutionAsync(string[] projectNames) + { + var templateProject = CreateProjectWithAdditionalFilesForCodeFix( + "public sealed class ReferenceTemplate;", + grainInterfacesFileContent: null); + var solution = new AdhocWorkspace().CurrentSolution; + foreach (var projectName in projectNames) + { + var projectId = ProjectId.CreateNewId(projectName); + solution = solution + .AddProject(projectId, projectName, projectName, LanguageNames.CSharp) + .WithProjectCompilationOptions( + projectId, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReferences(projectId, templateProject.MetadataReferences) + .AddDocument( + DocumentId.CreateNewId(projectId, $"{projectName}.cs"), + $"{projectName}.cs", + SourceText.From( + string.Join( + Environment.NewLine, + Usings.Select(@using => $"using {@using};").Append( + $"public interface I{projectName}Grain : IGrain {{ Task Ping(); }}")))) + .AddAdditionalDocument( + DocumentId.CreateNewId(projectId, OrleansContractsFileName), + OrleansContractsFileName, + SourceText.From("# OrleansContracts.txt\n")); + solution = AddContractsAnalyzerConfig(solution, projectId); + } + + var changedSolution = await ApplySolutionFixAllAsync( + solution.Projects.First(), + ThrowingFixAllDiagnosticProvider.Instance); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var project in changedSolution.Projects) + { + result.Add(project.Name, await GetOnlyContractsDocumentTextAsync(changedSolution, project.Id)); + } + + return result; + } + } + + [Fact] + public async Task CodeFix_RegenerateMissingWorkspaceDocument_UpdatesExistingDocumentId() + { + var project = CreateProjectWithAdditionalFilesForCodeFix( + "public interface IMyGrain : IGrain { Task Ping(); }", + string.Empty); + var originalDocument = Assert.Single(project.AdditionalDocuments); + var codeFixer = new GrainInterfaceVersionCodeFix(); + var actions = new List(); + var context = new CodeFixContext( + project.Documents.Single(), + CreateFixAllDiagnostic(GrainInterfaceVersionAnalyzer.RuleId0020), + (action, _) => actions.Add(action), + TestContext.Current.CancellationToken); + + await codeFixer.RegisterCodeFixesAsync(context); + var action = Assert.Single(actions, candidate => candidate.Title == RegenerateCodeActionTitle); + var operations = await action.GetOperationsAsync(TestContext.Current.CancellationToken); + var changedSolution = Assert.Single(operations.OfType()).ChangedSolution; + var changedProject = changedSolution.GetProject(project.Id)!; + var changedDocument = Assert.Single(changedProject.AdditionalDocuments); + var content = (await changedDocument.GetTextAsync(TestContext.Current.CancellationToken)).ToString(); + + Assert.Equal(originalDocument.Id, changedDocument.Id); + Assert.Contains("interface [GrainInterfaceType(\"IMyGrain\")] IMyGrain [Version(0)]", content); + } + + [Fact] + public Task DotNetFormat_CreatesMissingDefaultContractsFile() + => VerifyDotNetFormatCreatesMissingContractsFileAsync(configuredContractsPath: null); + + [Fact] + public Task DotNetFormat_CreatesMissingCustomContractsFileAndParentDirectory() + => VerifyDotNetFormatCreatesMissingContractsFileAsync( + Path.Combine("contracts", "CustomContracts.txt")); + + [Fact] + public Task DotNetFormat_CreatesMissingContractsFileUsingRepositoryBuildTargets() + => VerifyDotNetFormatCreatesMissingContractsFileAsync( + configuredContractsPath: null, + useRepositoryBuildTargets: true); + + private static async Task VerifyDotNetFormatCreatesMissingContractsFileAsync( + string? configuredContractsPath, + bool useRepositoryBuildTargets = false) + { + var repositoryRoot = GetRepositoryRoot(); + var tempDirectory = Path.Combine( + Path.GetTempPath(), + "OrleansContractsTests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + try + { + var projectPath = Path.Combine(tempDirectory, "ContractsCliTest.csproj"); + var contractsPath = Path.Combine( + tempDirectory, + configuredContractsPath ?? OrleansContractsFileName); + var analyzerPath = typeof(AlwaysInterleaveDiagnosticAnalyzer).Assembly.Location; + var contractsAnalyzerPath = typeof(GrainInterfaceVersionAnalyzer).Assembly.Location; + var abstractionsPath = typeof(IGrain).Assembly.Location; + var propsPath = Path.Combine( + repositoryRoot, + "src", + "Orleans.Analyzers", + "build", + "Microsoft.Orleans.Analyzers.props"); + var targetsPath = useRepositoryBuildTargets + ? Path.Combine(repositoryRoot, "src", "Directory.Build.targets") + : Path.Combine( + repositoryRoot, + "src", + "Orleans.Analyzers", + "build", + "Microsoft.Orleans.Analyzers.targets"); + var configuredPathProperty = configuredContractsPath is null + ? string.Empty + : $" $(MSBuildProjectDirectory)/{configuredContractsPath.Replace('\\', '/')}{Environment.NewLine}"; + var analyzerItem = $" {Environment.NewLine}"; + await File.WriteAllTextAsync( + projectPath, + $""" + + + + net10.0 + true + {EscapeXml(contractsAnalyzerPath)} + {configuredPathProperty} + + + {analyzerItem} + + + """, + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + Path.Combine(tempDirectory, "MyGrain.cs"), + """ + using System.Threading.Tasks; + using Orleans; + + public interface IMyGrain : IGrain + { + Task Ping(); + } + """, + TestContext.Current.CancellationToken); + + var restore = await RunDotNetAsync(repositoryRoot, "restore", projectPath, "--nologo"); + Assert.True(restore.ExitCode == 0, restore.Output); + Assert.False(File.Exists(contractsPath)); + + var missingManifestBuild = await RunDotNetAsync( + repositoryRoot, + "build", + projectPath, + "--no-restore", + "--nologo"); + Assert.True(missingManifestBuild.ExitCode == 0, missingManifestBuild.Output); + Assert.False(File.Exists(contractsPath)); + + var format = await RunDotNetAsync( + repositoryRoot, + "format", + projectPath, + "analyzers", + "--no-restore", + "--severity", + "info", + "--diagnostics", + GrainInterfaceVersionAnalyzer.RuleId0016, + GrainInterfaceVersionAnalyzer.RuleId0017, + GrainInterfaceVersionAnalyzer.RuleId0018, + GrainInterfaceVersionAnalyzer.RuleId0019, + GrainInterfaceVersionAnalyzer.RuleId0020, + GrainInterfaceVersionAnalyzer.RuleId0022, + GrainInterfaceVersionAnalyzer.RuleId0023, + GrainInterfaceVersionAnalyzer.RuleId0024); + Assert.True(format.ExitCode == 0, format.Output); + Assert.True(File.Exists(contractsPath), format.Output); + var content = await File.ReadAllTextAsync( + contractsPath, + TestContext.Current.CancellationToken); + Assert.StartsWith(GeneratedHeader, content); + Assert.Contains( + "interface [GrainInterfaceType(\"IMyGrain\")] IMyGrain [Version(0)]", + content); + + var build = await RunDotNetAsync( + repositoryRoot, + "build", + projectPath, + "--no-restore", + "--nologo"); + Assert.True(build.ExitCode == 0, build.Output); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static async Task<(int ExitCode, string Output)> RunDotNetAsync( + string workingDirectory, + params string[] arguments) + { + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = workingDirectory, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + var standardOutput = process!.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(2)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException( + $"dotnet {string.Join(' ', arguments)} exceeded the two-minute test timeout."); + } + + var output = string.Concat( + await standardOutput, + Environment.NewLine, + await standardError); + return (process.ExitCode, output); + } + + private static string EscapeXml(string value) + => System.Security.SecurityElement.Escape(value)!; + + private static string GetRepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "Orleans.slnx"))) + { + return directory.FullName; + } + } + + throw new DirectoryNotFoundException("Could not locate the Orleans repository root."); + } + + private static async Task ApplySolutionFixAllAsync( + Project project, + FixAllContext.DiagnosticProvider diagnosticProvider) + { + var codeFixer = new GrainInterfaceVersionCodeFix(); + var context = new FixAllContext( + project.Documents.First(), + codeFixer, + FixAllScope.Solution, + RegenerateCodeActionEquivalenceKey, + codeFixer.FixableDiagnosticIds, + diagnosticProvider, + TestContext.Current.CancellationToken); + var action = await codeFixer.GetFixAllProvider().GetFixAsync(context); + Assert.NotNull(action); + var operations = await action!.GetOperationsAsync(TestContext.Current.CancellationToken); + return Assert.Single(operations.OfType()).ChangedSolution; + } + + private static async Task GetOnlyContractsDocumentTextAsync(Solution solution, ProjectId projectId) + { + var document = Assert.Single(solution.GetProject(projectId)!.AdditionalDocuments); + return (await document.GetTextAsync(TestContext.Current.CancellationToken)).ToString(); + } + + private static Diagnostic CreateFixAllDiagnostic(string diagnosticId) => Diagnostic.Create( new DiagnosticDescriptor( - GrainInterfaceVersionAnalyzer.RuleId0016, + diagnosticId, "Contract missing", "Contract missing", "Versioning", @@ -2489,26 +3001,24 @@ private static Diagnostic CreateFixAllDiagnostic() isEnabledByDefault: true), Location.None); - private sealed class TestFixAllDiagnosticProvider( - IReadOnlyDictionary> diagnostics) : FixAllContext.DiagnosticProvider + private sealed class ThrowingFixAllDiagnosticProvider : FixAllContext.DiagnosticProvider { + public static ThrowingFixAllDiagnosticProvider Instance { get; } = new(); + public override Task> GetDocumentDiagnosticsAsync( Document document, CancellationToken cancellationToken) - => Task.FromResult(Enumerable.Empty()); + => throw new InvalidOperationException("Solution regeneration must not request diagnostics."); public override Task> GetProjectDiagnosticsAsync( Project project, CancellationToken cancellationToken) - => Task.FromResult(Enumerable.Empty()); + => throw new InvalidOperationException("Solution regeneration must not request diagnostics."); public override Task> GetAllDiagnosticsAsync( Project project, CancellationToken cancellationToken) - => Task.FromResult( - diagnostics.TryGetValue(project.Id, out var result) - ? result - : Enumerable.Empty()); + => throw new InvalidOperationException("Solution regeneration must not request diagnostics."); } #endregion diff --git a/test/Orleans.Analyzers.Tests/Orleans.Analyzers.Tests.csproj b/test/Orleans.Analyzers.Tests/Orleans.Analyzers.Tests.csproj index a579fd4cc72..422e2ad2b58 100644 --- a/test/Orleans.Analyzers.Tests/Orleans.Analyzers.Tests.csproj +++ b/test/Orleans.Analyzers.Tests/Orleans.Analyzers.Tests.csproj @@ -14,6 +14,7 @@ +