From 2f30cc180a22cc16fe201b5acecbc4843dca2c50 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 12 Sep 2026 15:01:21 +0100 Subject: [PATCH 1/2] chore: updating to latest source gen --- .../project-placement-defaults/SKILL.md | 2 +- .config/lefthook.yml | 2 +- Directory.Packages.props | 46 +-- Justfile | 1 + docs/configuration.md | 15 +- docs/diagnostics.md | 39 +- global.json | 4 +- package.json | 2 +- src/ResourceKit.slnx | 1 + .../Builder/WebApplicationExtensions.cs | 4 +- .../HostApplicationBuilderExtensions.cs | 6 +- src/src/ResourceKit/IOptionsBuilder.cs | 87 +---- src/src/ResourceKit/OptionsHelper.cs | 364 ++---------------- src/src/ResourceKit/ResourceKit.csproj | 6 + .../aspire-apphost-to-resourcekit/SKILL.md | 8 +- src/src/ResourceKit/Sdk/README.md | 17 +- .../OptionsHelperAssignCodeFixProvider.cs | 145 +++++++ .../SourceGeneration.CodeFixes.csproj | 13 + .../AnalyzerReleases.Unshipped.md | 5 +- .../Helpers/CodeGenEmiiter.Attributes.cs | 25 +- .../Helpers/CodeGenEmiiter.HostKit.cs | 8 +- .../Helpers/DiagnosticLibrary.cs | 21 +- .../SourceGeneration/Helpers/GeneratedText.cs | 2 +- .../Helpers/HintNameHelper.cs | 4 +- .../Helpers/ResourceKitRules.cs | 76 +++- .../Helpers/SourceGenLibrary.cs | 14 +- src/src/SourceGeneration/HostKitGenerator.cs | 11 +- .../Models/KitGenerationModels.cs | 18 +- .../OptionsHelperAssignAnalyzer.cs | 147 +++++++ .../HostAppResourceTests.cs | 24 +- .../Models/OptionsModels.cs | 4 +- .../OptionsHelperTests.cs | 204 +++++----- .../DiagnosticMessageRenderingTests.cs | 2 +- .../GeneratedSourceContentTests.cs | 8 +- .../GeneratorCachingTests.cs | 6 +- .../OptionsHelperAssignAnalyzerTests.cs | 216 +++++++++++ .../OptionsHelperAssignCodeFixTests.cs | 104 +++++ .../ProjectResourceDefinitionTests.cs | 46 +++ .../ResourceKitDiagnosticSuppressorTests.cs | 10 +- .../ResourcePropertyNeverSetTests.cs | 38 ++ .../SourceGeneration.IntegrationTests.csproj | 5 + .../ExecutionOnlyRuleSeverityTests.cs | 69 ++++ 42 files changed, 1160 insertions(+), 669 deletions(-) create mode 100644 src/src/SourceGeneration.CodeFixes/OptionsHelperAssignCodeFixProvider.cs create mode 100644 src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj create mode 100644 src/src/SourceGeneration/OptionsHelperAssignAnalyzer.cs create mode 100644 src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs create mode 100644 src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignCodeFixTests.cs create mode 100644 src/tests/SourceGeneration.UnitTests/ExecutionOnlyRuleSeverityTests.cs diff --git a/.agents/skills/project-placement-defaults/SKILL.md b/.agents/skills/project-placement-defaults/SKILL.md index 1a2d3e5..4694865 100644 --- a/.agents/skills/project-placement-defaults/SKILL.md +++ b/.agents/skills/project-placement-defaults/SKILL.md @@ -58,7 +58,7 @@ Align identities with existing repository conventions: - Test project names should clearly indicate scope/type with recognized test suffixes. - `NamespacePrefix` should remain the root identity source for the repo. - `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required. -- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace`, so a project's package/assembly identity follows its namespace unless the repo explicitly overrides `AssemblyName`/`PackageId` or opts out via `EnableAssemblyNameGeneration=false`. +- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — or to the full logical project name when suffix-stripping removed a segment (e.g. `Shared`, `ServiceDefaults`) — so a project's package/assembly identity follows its namespace and stays distinct, unless the repo explicitly overrides `AssemblyName`/`PackageId`/`RootNamespace` or opts out via `EnableAssemblyNameGeneration=false`. - When moving files between projects, update namespaces so they match the destination project's conventions. Do not invent a new naming scheme when an existing one is already in use. diff --git a/.config/lefthook.yml b/.config/lefthook.yml index 056bb5a..cd41de9 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -6,4 +6,4 @@ pre-commit: commit-msg: jobs: - name: conventional commits - run: npx commitlint --edit {1} + run: bunx commitlint --edit {1} diff --git a/Directory.Packages.props b/Directory.Packages.props index a1c4570..a756184 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,11 +1,15 @@ true - 1.65.63 - 1.0.0-prerelease.37 + 5.9.0 + 1.67.0 + 1.0.0-prerelease.39 + 10.0.12 + 10.10.0 + 1.18.0 - + @@ -18,18 +22,16 @@ - - - - - - - - - - - + + + + + + + + + + - - - - - - - + + + + + + + diff --git a/Justfile b/Justfile index dabdd17..b7d7ebd 100644 --- a/Justfile +++ b/Justfile @@ -50,6 +50,7 @@ pipeline-release *args: [group('Pipeline')] pipeline-local-release *args: just ensure-pipeline-tool + just lint-fix echo "Running local release pipeline..." "{{ pipeline_tool }}" --Release:Mode=LocalNuGet {{ args }} diff --git a/docs/configuration.md b/docs/configuration.md index 91c9594..f4ee435 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,16 +99,19 @@ Use this hook to react to runtime state, for example environment-specific availa Use `OptionsHelper` to generate command-line configuration arguments from strongly typed assignments. ```csharp -var args = OptionsHelper.ForSet( +var args = OptionsHelper.Assign( c => c.API.IsEnabled = false, c => c.API.Name = "api-test" ).Build(); ``` -For a single argument, use `ForOne` with a member selector and access the first element: +Each assignment action must set exactly one property path. To set multiple properties, pass one assignment per property (as above). The compiler reports `SG0020` if an action assigns more than one property path, and offers a code fix that splits it into separate assignments. + +If you need a property path as a plain string (for example, to build keys or log config), use `PathFor` with a member selector: ```csharp -var arg = OptionsHelper.ForOne(f => f.API.Name).Build()[0]; +var path = OptionsHelper.PathFor(f => f.API.Name); +// "API.Name" ``` Resulting args are in this form: @@ -119,7 +122,7 @@ Resulting args are in this form: To produce environment variables instead, call `AsEnvironmentVariables()` before `Build()`: ```csharp -var envVars = OptionsHelper.ForSet( +var envVars = OptionsHelper.Assign( c => c.API.IsEnabled = false, c => c.API.Name = "api-test" ).AsEnvironmentVariables().Build(); @@ -134,11 +137,11 @@ This returns a dictionary such as `{"ShopHostKit__API__IsEnabled": "false", "Sho > protected override string[] Args => > [ > .. base.Args, -> .. OptionsHelper.ForSet( +> .. OptionsHelper.Assign( > c => c.API.IsEnabled = false, > c => c.API.Name = "api-test" > ).Build(), ->]; +> ]; > ``` ## Section-name resolution order diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 7210f8f..dd9719d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -30,8 +30,43 @@ exiting the constructor" warning does not apply. | SG0015 | Error | Generic `ResourceDefinition` cannot declare explicit base type | | SG0016 | Error | No Aspire resource type could be inferred/found | | SG0017 | Warning | An `IResourceBuilder` property is never assigned in `BuildResource` or `ConfigureResource` | -| SG0018 | Error | A project resource kit does not add the declared project via `AddProject()` | -| SG0019 | Error | A project resource kit declares an explicit base class that does not use `ProjectResource` | +| SG0018 | Warning | A project resource kit does not add the declared project via `AddProject()` | +| SG0019 | Warning | A project resource kit declares an explicit base class that does not use `ProjectResource` | +| SG0020 | Error | An `OptionsHelper.Assign` action sets more than one property path | + +## Execution-only vs generation-blocking rules + +SG0017, SG0018, and SG0019 are **execution-only** rules. They report problems that break the resource at +runtime (an unset builder property, a project that is never registered, or a base class that cannot build +a project) but they do **not** prevent source generation. They are reported as warnings so generation +always proceeds — for example, a resource kit whose `BuildResource` does not yet register its declared +project via `AddProject()` is still generated (and the host kit is still emitted) so the user can +complete the override instead of losing the whole output. Only Error-severity rules (SG0001–SG0016) block +generation. + +## `OptionsHelper.Assign` action with multiple property paths (SG0020) + +Each `OptionsHelper.Assign(...)` action must set exactly one property path. A block-bodied lambda +such as the following is rejected at compile time: + +```csharp +OptionsHelper.Assign(o => +{ + o.API.IsEnabled = false; + o.API.Name = "api-test"; +}); +``` + +Split each property into its own assignment argument instead: + +```csharp +OptionsHelper.Assign( + o => o.API.IsEnabled = false, + o => o.API.Name = "api-test" +); +``` + +Visual Studio offers a **"Split into separate assignments"** code fix that performs this conversion for you. ## Fast troubleshooting checklist diff --git a/global.json b/global.json index 0425cf8..d302539 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,9 @@ { "sdk": { - "version": "10.0.400", - "rollForward": "latestMajor", "allowPrerelease": false }, "msbuild-sdks": { - "Purview.DotNetProjectSdk": "1.0.0-prerelease.47" + "Purview.DotNetProjectSdk": "1.0.0-prerelease.51" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index 958a708..e9e3352 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "purview-aspire-appresources", - "version": "1.0.0-prerelease.24", + "version": "1.0.0-prerelease.25", "keywords": [], "homepage": "https://github.com/purview-dev/aspire-appresources#readme", "bugs": { diff --git a/src/ResourceKit.slnx b/src/ResourceKit.slnx index 1137c0b..acd30ff 100644 --- a/src/ResourceKit.slnx +++ b/src/ResourceKit.slnx @@ -13,6 +13,7 @@ + diff --git a/src/src/Example.ServiceDefaults/Extensions/Microsoft/AspNetCore/Builder/WebApplicationExtensions.cs b/src/src/Example.ServiceDefaults/Extensions/Microsoft/AspNetCore/Builder/WebApplicationExtensions.cs index d895bd7..ca30d5a 100644 --- a/src/src/Example.ServiceDefaults/Extensions/Microsoft/AspNetCore/Builder/WebApplicationExtensions.cs +++ b/src/src/Example.ServiceDefaults/Extensions/Microsoft/AspNetCore/Builder/WebApplicationExtensions.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; @@ -21,7 +21,7 @@ public WebApplication MapDefaultEndpoints() // Only health checks tagged with the "live" tag must pass for app to be considered alive app.MapHealthChecks( Platform.EndpointsDefinitions.Aliveness, - new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") } + new HealthCheckOptions { Predicate = static r => r.Tags.Contains("live") } ); } diff --git a/src/src/Example.ServiceDefaults/Extensions/Microsoft/Extensions/Hosting/HostApplicationBuilderExtensions.cs b/src/src/Example.ServiceDefaults/Extensions/Microsoft/Extensions/Hosting/HostApplicationBuilderExtensions.cs index 37730b6..f648704 100644 --- a/src/src/Example.ServiceDefaults/Extensions/Microsoft/Extensions/Hosting/HostApplicationBuilderExtensions.cs +++ b/src/src/Example.ServiceDefaults/Extensions/Microsoft/Extensions/Hosting/HostApplicationBuilderExtensions.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; @@ -22,7 +22,7 @@ public TBuilder AddServiceDefaults() builder.Services.AddServiceDiscovery(); - builder.Services.ConfigureHttpClientDefaults(http => + builder.Services.ConfigureHttpClientDefaults(static http => { // Turn on resilience by default http.AddStandardResilienceHandler(); @@ -102,7 +102,7 @@ public TBuilder AddDefaultHealthChecks() builder .Services.AddHealthChecks() // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + .AddCheck("self", static () => HealthCheckResult.Healthy(), ["live"]); return builder; } diff --git a/src/src/ResourceKit/IOptionsBuilder.cs b/src/src/ResourceKit/IOptionsBuilder.cs index c1ab072..f9e4403 100644 --- a/src/src/ResourceKit/IOptionsBuilder.cs +++ b/src/src/ResourceKit/IOptionsBuilder.cs @@ -1,7 +1,3 @@ -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -using System.Runtime.CompilerServices; - namespace Purview.Aspire.ResourceKit; /// @@ -15,7 +11,7 @@ public interface IOptionsBuilder /// The root options type. /// One or more property assignment actions. /// The same builder. - IOptionsBuilder ForSet(params Action[] assignments); + IOptionsBuilder Assign(params Action[] assignments); /// /// Adds entries from assignment expressions for the specified options type with an explicit root section name. @@ -24,86 +20,7 @@ public interface IOptionsBuilder /// The root section name override. /// One or more property assignment actions. /// The same builder. - IOptionsBuilder ForSet(string sectionName, params Action[] assignments); - - /// - /// Adds a single entry from an assignment expression for the specified options type. - /// - /// The root options type. - /// A property assignment expression. - /// Captured source text for . - /// The same builder. - IOptionsBuilder ForSetOne( - Action assignment, - [CallerArgumentExpression(nameof(assignment))] string assignmentExpression = "" - ); - - /// - /// Adds a single entry from an assignment expression for the specified options type with an explicit root section name. - /// - /// The root options type. - /// The root section name override. - /// A property assignment expression. - /// Captured source text for . - /// The same builder. - IOptionsBuilder ForSetOne( - string sectionName, - Action assignment, - [CallerArgumentExpression(nameof(assignment))] string assignmentExpression = "" - ); - - /// - /// Adds a single entry from a member selector expression for the specified options type. - /// - /// The root options type. - /// A member selector expression. - /// The same builder. - IOptionsBuilder ForOne(Expression> selector); - - /// - /// Adds a single entry from a member selector expression for the specified options type with an explicit root section name. - /// - /// The root options type. - /// The root section name override. - /// A member selector expression. - /// The same builder. - IOptionsBuilder ForOne(string sectionName, Expression> selector); - - /// - /// Adds entries from multiple member selector expressions for the specified options type. - /// - /// The root options type. - /// The first member selector expression. - /// Additional member selector expressions. - /// The same builder. - [SuppressMessage( - "Naming", - "CA1716:Identifiers should not match keywords", - Justification = "For is the established OptionsHelper entry point name; it is not a C# keyword and is used only in this context." - )] - IOptionsBuilder For( - Expression> selector, - params Expression>[] selectors - ); - - /// - /// Adds entries from multiple member selector expressions for the specified options type with an explicit root section name. - /// - /// The root options type. - /// The root section name override. - /// The first member selector expression. - /// Additional member selector expressions. - /// The same builder. - [SuppressMessage( - "Naming", - "CA1716:Identifiers should not match keywords", - Justification = "For is the established OptionsHelper entry point name; it is not a C# keyword and is used only in this context." - )] - IOptionsBuilder For( - string sectionName, - Expression> selector, - params Expression>[] selectors - ); + IOptionsBuilder Assign(string sectionName, params Action[] assignments); /// /// Builds the collected entries as command-line arguments (default mode). diff --git a/src/src/ResourceKit/OptionsHelper.cs b/src/src/ResourceKit/OptionsHelper.cs index 85e4788..6359f5f 100644 --- a/src/src/ResourceKit/OptionsHelper.cs +++ b/src/src/ResourceKit/OptionsHelper.cs @@ -7,7 +7,7 @@ namespace Purview.Aspire.ResourceKit; /// -/// Builds configuration arguments or environment variables for options objects by using assignment or selector expressions. +/// Builds configuration arguments or environment variables for options objects by using assignment expressions. /// public static class OptionsHelper { @@ -28,11 +28,11 @@ sealed class ReferenceComparer : IEqualityComparer /// The root options type. /// One or more property assignment actions. /// A builder that can be extended or built. - public static IOptionsBuilder ForSet(params Action[] assignments) + public static IOptionsBuilder Assign(params Action[] assignments) { ArgumentNullException.ThrowIfNull(assignments); - return new OptionsBuilder().ForSet(assignments); + return new OptionsBuilder().Assign(assignments); } /// @@ -42,112 +42,24 @@ public static IOptionsBuilder ForSet(params Action[] assignm /// The root section name override. /// One or more property assignment actions. /// A builder that can be extended or built. - public static IOptionsBuilder ForSet(string sectionName, params Action[] assignments) + public static IOptionsBuilder Assign(string sectionName, params Action[] assignments) { ArgumentNullException.ThrowIfNull(assignments); - return new OptionsBuilder().ForSet(sectionName, assignments); + return new OptionsBuilder().Assign(sectionName, assignments); } /// - /// Starts building entries from a single assignment expression for the specified options type. - /// - /// The root options type. - /// A property assignment expression. - /// Captured source text for . - /// A builder that can be extended or built. - public static IOptionsBuilder ForSetOne( - Action assignment, - [CallerArgumentExpression(nameof(assignment))] string assignmentExpression = "" - ) - { - ArgumentNullException.ThrowIfNull(assignment); - - return new OptionsBuilder().ForSetOne(assignment, assignmentExpression); - } - - /// - /// Starts building entries from a single assignment expression for the specified options type with an explicit root section name. - /// - /// The root options type. - /// The root section name override. - /// A property assignment expression. - /// Captured source text for . - /// A builder that can be extended or built. - public static IOptionsBuilder ForSetOne( - string sectionName, - Action assignment, - [CallerArgumentExpression(nameof(assignment))] string assignmentExpression = "" - ) - { - ArgumentNullException.ThrowIfNull(assignment); - - return new OptionsBuilder().ForSetOne(sectionName, assignment, assignmentExpression); - } - - /// - /// Starts building entries from a member selector expression for the specified options type. + /// Gets the dot-separated member path for a property selector on the specified options type. /// /// The root options type. /// A member selector expression. - /// A builder that can be extended or built. - public static IOptionsBuilder ForOne(Expression> selector) - { - ArgumentNullException.ThrowIfNull(selector); - - return new OptionsBuilder().ForOne(selector); - } - - /// - /// Starts building entries from a member selector expression for the specified options type with an explicit root section name. - /// - /// The root options type. - /// The root section name override. - /// A member selector expression. - /// A builder that can be extended or built. - public static IOptionsBuilder ForOne(string sectionName, Expression> selector) - { - ArgumentNullException.ThrowIfNull(selector); - - return new OptionsBuilder().ForOne(sectionName, selector); - } - - /// - /// Starts building entries from multiple member selector expressions for the specified options type. - /// - /// The root options type. - /// The first member selector expression. - /// Additional member selector expressions. - /// A builder that can be extended or built. - public static IOptionsBuilder For( - Expression> selector, - params Expression>[] selectors - ) - { - ArgumentNullException.ThrowIfNull(selector); - ArgumentNullException.ThrowIfNull(selectors); - - return new OptionsBuilder().For(selector, selectors); - } - - /// - /// Starts building entries from multiple member selector expressions for the specified options type with an explicit root section name. - /// - /// The root options type. - /// The root section name override. - /// The first member selector expression. - /// Additional member selector expressions. - /// A builder that can be extended or built. - public static IOptionsBuilder For( - string sectionName, - Expression> selector, - params Expression>[] selectors - ) + /// The dot-separated member path (for example, Level1.Level2.Name). + public static string PathFor(Expression> selector) { ArgumentNullException.ThrowIfNull(selector); - ArgumentNullException.ThrowIfNull(selectors); - return new OptionsBuilder().For(sectionName, selector, selectors); + return GetMemberPath(selector); } static string ResolveSectionName(string? sectionNameOverride) @@ -205,53 +117,6 @@ static TOptions CreateRootOptionsInstance() : (TOptions)instance; } - static string GetMemberPath(string assignmentExpression) - { - if (string.IsNullOrWhiteSpace(assignmentExpression)) - throw new ArgumentException( - "Assignment expression text could not be captured.", - nameof(assignmentExpression) - ); - - var arrowIndex = assignmentExpression.IndexOf("=>", StringComparison.Ordinal); - if (arrowIndex < 0) - throw new ArgumentException( - $"Expression '{assignmentExpression}' must be a lambda assignment expression.", - nameof(assignmentExpression) - ); - - var rhs = assignmentExpression[(arrowIndex + 2)..].Trim(); - if (rhs.StartsWith('{')) - { - var statementEnd = rhs.IndexOf(';', StringComparison.Ordinal); - if (statementEnd > 0) - rhs = rhs[1..statementEnd].Trim(); - } - - var assignIndex = rhs.IndexOf('=', StringComparison.Ordinal); - if (assignIndex < 0) - throw new ArgumentException( - $"Expression '{assignmentExpression}' must contain an assignment operator.", - nameof(assignmentExpression) - ); - - var left = rhs[..assignIndex].Trim(); - var firstDot = left.IndexOf('.', StringComparison.Ordinal); - if (firstDot < 0 || firstDot == left.Length - 1) - throw new ArgumentException( - $"Expression '{assignmentExpression}' must assign a member path on the options parameter.", - nameof(assignmentExpression) - ); - - var path = left[(firstDot + 1)..].Replace("!", string.Empty, StringComparison.Ordinal).Trim(); - return string.IsNullOrWhiteSpace(path) - ? throw new ArgumentException( - $"Expression '{assignmentExpression}' does not contain a valid member path.", - nameof(assignmentExpression) - ) - : path.Replace('.', ':'); - } - static string GetMemberPath(Expression> selector) { ArgumentNullException.ThrowIfNull(selector); @@ -263,7 +128,7 @@ static string GetMemberPath(Expression> select body = unary.Operand; #pragma warning restore format - var segments = new List(); + List segments = []; while (body is MemberExpression member) { segments.Add(member.Member.Name); @@ -288,65 +153,7 @@ static string GetMemberPath(Expression> select ); segments.Reverse(); - return string.Join(':', segments); - } - - static void EnsurePathObjectsExist(object root, string keyPath) - { - var segments = keyPath.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length < 2) - return; - - var current = root; - for (var i = 0; i < segments.Length - 1; i++) - { - var property = - current - .GetType() - .GetProperty(segments[i], BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - ?? throw new InvalidOperationException( - $"Property '{segments[i]}' was not found on '{current.GetType().FullName}'." - ); - - var value = property.GetValue(current); - if (value is null) - { - var instance = CreateInstance(property.PropertyType); - if (property.SetMethod is null) - throw new InvalidOperationException( - $"Property '{property.Name}' on '{current.GetType().FullName}' is null and does not have a setter." - ); - - property.SetValue(current, instance); - value = instance; - } - - current = value; - } - } - - static object? GetPathValue(object root, string keyPath) - { - var segments = keyPath.Split(':', StringSplitOptions.RemoveEmptyEntries); - var current = root; - - foreach (var segment in segments) - { - if (current is null) - return null; - - var property = - current - .GetType() - .GetProperty(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - ?? throw new InvalidOperationException( - $"Property '{segment}' was not found on '{current.GetType().FullName}'." - ); - - current = property.GetValue(current); - } - - return current; + return string.Join('.', segments); } static object CreateInstance(Type type) @@ -677,52 +484,19 @@ var property in a.GetType() assignment(a); assignment(b); - var candidates = new List<(string Path, object? Value)>(); + List<(string Path, object? Value)> candidates = []; CollectEqualLeafPaths(a, b, string.Empty, candidates, [with(ReferenceComparer.Instance)]); - return candidates.Count != 1 - ? throw new InvalidOperationException( - $"Each assignment action must set exactly one property path. Found {candidates.Count} candidate paths." - ) - : candidates[0]; - } - - static OptionsEntry BuildEntryFromAssignment( - Action assignment, - string assignmentExpression, - string sectionName - ) - { - ArgumentNullException.ThrowIfNull(assignment); - - var keyPath = GetMemberPath(assignmentExpression); - var root = CreateRootOptionsInstance(); - ArgumentNullException.ThrowIfNull(root); - - EnsurePathObjectsExist(root, keyPath); - assignment(root); - - var value = GetPathValue(root, keyPath); - var valueText = ToCommandLineValue(value); - - return new OptionsEntry(sectionName, keyPath, valueText); - } - - static OptionsEntry BuildEntryFromSelector( - Expression> selector, - string sectionName - ) - { - var keyPath = GetMemberPath(selector); - var root = CreateRootOptionsInstance(); - ArgumentNullException.ThrowIfNull(root); - - EnsurePathObjectsExist(root, keyPath); - - var value = selector.Compile()(root); - var valueText = ToCommandLineValue(value); - - return new OptionsEntry(sectionName, keyPath, valueText); + return candidates.Count switch + { + 0 => throw new InvalidOperationException( + "The assignment action did not modify any detectable property path. Assign a value that differs from the property's current value, or verify the property has a setter." + ), + 1 => candidates[0], + _ => throw new InvalidOperationException( + $"Each assignment action must set exactly one property path. Found {candidates.Count} candidate paths: {string.Join(", ", candidates.Select(static c => c.Path))}. Split each property into its own assignment, for example Assign(o => o.Path1 = ..., o => o.Path2 = ...)." + ), + }; } static OptionsEntry[] BuildEntriesFromActions(string? sectionNameOverride, Action[] assignments) @@ -746,7 +520,7 @@ sealed class OptionsBuilder : IOptionsBuilder { readonly List _entries = []; - public IOptionsBuilder ForSet(params Action[] assignments) + public IOptionsBuilder Assign(params Action[] assignments) { ArgumentNullException.ThrowIfNull(assignments); @@ -757,7 +531,7 @@ public IOptionsBuilder ForSet(params Action[] assignments) return this; } - public IOptionsBuilder ForSet(string sectionName, params Action[] assignments) + public IOptionsBuilder Assign(string sectionName, params Action[] assignments) { ArgumentNullException.ThrowIfNull(assignments); @@ -768,94 +542,6 @@ public IOptionsBuilder ForSet(string sectionName, params Action( - Action assignment, - [CallerArgumentExpression(nameof(assignment))] string assignmentExpression = "" - ) - { - ArgumentNullException.ThrowIfNull(assignment); - - _entries.Add( - BuildEntryFromAssignment( - assignment, - assignmentExpression, - ResolveSectionName(sectionNameOverride: null) - ) - ); - return this; - } - - public IOptionsBuilder ForSetOne( - string sectionName, - Action assignment, - [CallerArgumentExpression(nameof(assignment))] string assignmentExpression = "" - ) - { - ArgumentNullException.ThrowIfNull(assignment); - - _entries.Add( - BuildEntryFromAssignment(assignment, assignmentExpression, ResolveSectionName(sectionName)) - ); - return this; - } - - public IOptionsBuilder ForOne(Expression> selector) - { - ArgumentNullException.ThrowIfNull(selector); - - _entries.Add(BuildEntryFromSelector(selector, ResolveSectionName(sectionNameOverride: null))); - return this; - } - - public IOptionsBuilder ForOne(string sectionName, Expression> selector) - { - ArgumentNullException.ThrowIfNull(selector); - - _entries.Add(BuildEntryFromSelector(selector, ResolveSectionName(sectionName))); - return this; - } - - public IOptionsBuilder For( - Expression> selector, - params Expression>[] selectors - ) - { - ArgumentNullException.ThrowIfNull(selector); - ArgumentNullException.ThrowIfNull(selectors); - - var sectionName = ResolveSectionName(sectionNameOverride: null); - _entries.Add(BuildEntryFromSelector(selector, sectionName)); - - for (var i = 0; i < selectors.Length; i++) - { - ArgumentNullException.ThrowIfNull(selectors[i]); - _entries.Add(BuildEntryFromSelector(selectors[i], sectionName)); - } - - return this; - } - - public IOptionsBuilder For( - string sectionName, - Expression> selector, - params Expression>[] selectors - ) - { - ArgumentNullException.ThrowIfNull(selector); - ArgumentNullException.ThrowIfNull(selectors); - - var resolvedSectionName = ResolveSectionName(sectionName); - _entries.Add(BuildEntryFromSelector(selector, resolvedSectionName)); - - for (var i = 0; i < selectors.Length; i++) - { - ArgumentNullException.ThrowIfNull(selectors[i]); - _entries.Add(BuildEntryFromSelector(selectors[i], resolvedSectionName)); - } - - return this; - } - public string[] Build() { var args = new string[_entries.Count]; @@ -875,7 +561,7 @@ sealed class EnvironmentVariablesBuilder(List entries) : IEnvironm { public IReadOnlyDictionary Build() { - var result = new Dictionary(); + Dictionary result = []; foreach (var entry in entries) { var key = $"{entry.SectionName}__{entry.KeyPath}".Replace(":", "__", StringComparison.Ordinal); diff --git a/src/src/ResourceKit/ResourceKit.csproj b/src/src/ResourceKit/ResourceKit.csproj index 4cad5b5..f4f49bd 100644 --- a/src/src/ResourceKit/ResourceKit.csproj +++ b/src/src/ResourceKit/ResourceKit.csproj @@ -24,5 +24,11 @@ ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> + diff --git a/src/src/ResourceKit/Sdk/.agents/skills/aspire-apphost-to-resourcekit/SKILL.md b/src/src/ResourceKit/Sdk/.agents/skills/aspire-apphost-to-resourcekit/SKILL.md index 31069eb..2afee36 100644 --- a/src/src/ResourceKit/Sdk/.agents/skills/aspire-apphost-to-resourcekit/SKILL.md +++ b/src/src/ResourceKit/Sdk/.agents/skills/aspire-apphost-to-resourcekit/SKILL.md @@ -87,10 +87,10 @@ Map old configuration into generated options conventions: - Resource naming → `{HostKitOptionsSection}:{ResourceProperty}:Name` - Enable/disable flags → `{HostKitOptionsSection}:{ResourceProperty}:IsEnabled` -If tests or bootstrap code currently pass args directly, convert to `OptionsHelper.ForSet(...)`: +If tests or bootstrap code currently pass args directly, convert to `OptionsHelper.Assign(...)`: ```csharp -var args = OptionsHelper.ForSet( +var args = OptionsHelper.Assign( c => c.Redis.IsEnabled = false, c => c.AzureStorage.Name = "custom-storage" ).Build(); @@ -173,7 +173,7 @@ Then keep the standard build/run flow. When tests/config exist, migrate them to ResourceKit semantics: -- Replace direct key/value arg strings with `OptionsHelper.ForSet(...)` where practical. +- Replace direct key/value arg strings with `OptionsHelper.Assign(...)` where practical. - Update assertions to verify: - resources disabled via options are absent/inaccessible, - custom names flow through to produced resources, @@ -252,7 +252,7 @@ Promote hard-coded values to typed options incrementally: Then wire tests/fixtures with: ```csharp -OptionsHelper.ForSet( +OptionsHelper.Assign( c => c.Sql.IsEnabled = true, c => c.Migrations.UseBundleInRunMode = true ).Build(); diff --git a/src/src/ResourceKit/Sdk/README.md b/src/src/ResourceKit/Sdk/README.md index 527032e..25c0a53 100644 --- a/src/src/ResourceKit/Sdk/README.md +++ b/src/src/ResourceKit/Sdk/README.md @@ -184,16 +184,17 @@ Use these values through generated properties: `OptionsHelper` converts typed assignment expressions into command-line configuration args: ```csharp -var args = OptionsHelper.ForSet( +var args = OptionsHelper.Assign( c => c.Redis.IsEnabled = false, c => c.Redis.Name = "dev-redis" ).Build(); ``` -For a single value, use `ForOne` with a member selector and access the first element: +Each assignment action must set exactly one property path. If you need a property path as a plain string (for example, for logging or string building), use `PathFor` with a member selector: ```csharp -var arg = OptionsHelper.ForOne(f => f.Redis.Name).Build()[0]; +var path = OptionsHelper.PathFor(f => f.Redis.Name); +// "Redis.Name" ``` Produces values like: @@ -204,7 +205,7 @@ Produces values like: Switch to environment variables with `AsEnvironmentVariables()`: ```csharp -var envVars = OptionsHelper.ForSet( +var envVars = OptionsHelper.Assign( c => c.Redis.IsEnabled = false ).AsEnvironmentVariables().Build(); ``` @@ -231,5 +232,13 @@ Useful for integration-test fixtures and scenario toggles. | SG0014 | Error | Non-generic `ResourceDefinition` requires an explicit compatible base type | | SG0015 | Error | Generic `ResourceDefinition` must not declare an explicit base type | | SG0016 | Error | No Aspire resource type could be inferred/found | +| SG0017 | Warning | `IResourceBuilder` property never assigned in `BuildResource`/`ConfigureResource` (execution-only) | +| SG0018 | Warning | Project resource kit does not add the declared project via `AddProject()` (execution-only) | +| SG0019 | Warning | Project resource kit explicit base does not use `ProjectResource` (execution-only) | +| SG0020 | Error | `OptionsHelper.Assign` action sets more than one property path | + +SG0017–SG0019 are execution-only warnings: they indicate the resource will fail at runtime but never +block generation, so a resource kit with incomplete wiring (for example a project not yet added via +`AddProject()`) is still generated and the host kit output is still emitted. For troubleshooting guidance, see [`/docs/diagnostics.md`](https://github.com/purview-dev/purview-aspire-resourcekit/blob/main/docs/diagnostics.md). diff --git a/src/src/SourceGeneration.CodeFixes/OptionsHelperAssignCodeFixProvider.cs b/src/src/SourceGeneration.CodeFixes/OptionsHelperAssignCodeFixProvider.cs new file mode 100644 index 0000000..8062e1e --- /dev/null +++ b/src/src/SourceGeneration.CodeFixes/OptionsHelperAssignCodeFixProvider.cs @@ -0,0 +1,145 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes; + +/// +/// Provides a code fix for SG0020 that splits a block-bodied OptionsHelper.Assign action +/// assigning multiple property paths into one assignment per argument. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(OptionsHelperAssignCodeFixProvider))] +public sealed class OptionsHelperAssignCodeFixProvider : CodeFixProvider +{ + const string OptionsHelperAssignRuleId = "SG0020"; + + public override ImmutableArray FixableDiagnosticIds => [OptionsHelperAssignRuleId]; + + public override FixAllProvider? GetFixAllProvider() => null; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var lambda = node as LambdaExpressionSyntax ?? node?.FirstAncestorOrSelf(); + var invocation = lambda?.Ancestors().OfType().FirstOrDefault(); + if (invocation is null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + title: "Split into separate assignments", + createChangedDocument: cancellationToken => + SplitAssignmentsAsync(context.Document, invocation, cancellationToken), + equivalenceKey: "SplitIntoSeparateAssignments" + ), + diagnostic + ); + } + + static async Task SplitAssignmentsAsync( + Document document, + InvocationExpressionSyntax invocation, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + List newArguments = []; + foreach (var argument in invocation.ArgumentList.Arguments) + { + if ( + argument.Expression is not LambdaExpressionSyntax lambda + || !TryGetBlockAssignments(lambda, out var assignments) + || assignments.Count < 2 + ) + { + newArguments.Add(argument); + continue; + } + + var distinctPaths = assignments + .Select(static assignment => assignment.Left.ToString()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (distinctPaths.Length < 2) + { + newArguments.Add(argument); + continue; + } + + var parameter = lambda switch + { + SimpleLambdaExpressionSyntax simple => simple.Parameter, + ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList.Parameters[0], + _ => null, + }; + if (parameter is null) + { + newArguments.Add(argument); + continue; + } + + foreach (var assignment in assignments) + { + var simpleLambda = SyntaxFactory.SimpleLambdaExpression( + parameter.WithoutTrivia(), + assignment.WithoutTrivia() + ); + newArguments.Add(SyntaxFactory.Argument(simpleLambda)); + } + } + + var separators = Enumerable.Repeat( + SyntaxFactory.Token(SyntaxKind.CommaToken), + Math.Max(0, newArguments.Count - 1) + ); + var newArgumentList = SyntaxFactory + .ArgumentList(SyntaxFactory.SeparatedList(newArguments, separators)) + .WithTriviaFrom(invocation.ArgumentList) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newInvocation = invocation.WithArgumentList(newArgumentList); + var newRoot = root.ReplaceNode(invocation, newInvocation); + return await Formatter + .FormatAsync(document.WithSyntaxRoot(newRoot), cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + static bool TryGetBlockAssignments(LambdaExpressionSyntax lambda, out List assignments) + { + BlockSyntax? body = null; + if (lambda is SimpleLambdaExpressionSyntax simpleLambda && simpleLambda.Body is BlockSyntax simpleBlock) + body = simpleBlock; + else if ( + lambda is ParenthesizedLambdaExpressionSyntax parenthesized + && parenthesized.Body is BlockSyntax parenthesizedBlock + ) + body = parenthesizedBlock; + + if (body is null) + { + assignments = []; + return false; + } + + assignments = + [ + .. body.DescendantNodes() + .OfType() + .Where(static assignment => assignment.Left is MemberAccessExpressionSyntax), + ]; + + return true; + } +} diff --git a/src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj b/src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj new file mode 100644 index 0000000..05d5a59 --- /dev/null +++ b/src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj @@ -0,0 +1,13 @@ + + + netstandard2.0 + latest + enable + true + true + + + + + + diff --git a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md index e0b2e0c..5f5b5f5 100644 --- a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md @@ -3,5 +3,6 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------ SG0017 | Purview.Aspire.ResourceKit.SourceGenerator | Warning | Resource property is never set -SG0018 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Project resource definition mismatch -SG0019 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Project resource kit base must use ProjectResource +SG0018 | Purview.Aspire.ResourceKit.SourceGenerator | Warning | Project resource definition mismatch +SG0019 | Purview.Aspire.ResourceKit.SourceGenerator | Warning | Project resource kit base must use ProjectResource +SG0020 | Purview.Aspire.ResourceKit.SourceGenerator | Error | OptionsHelper.Assign action must set exactly one property path diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs index cb414e6..709c1b1 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs @@ -13,13 +13,13 @@ static SourceText HostKitAttribute() .AttributeClass( new TypeDeclarationOptions(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute), AttributeTargets.Class, - attributeBody => + static attributeBody => { attributeBody .XmlSummary("Initializes a new instance of the HostKitAttribute class.") .Constructor( new(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute), - ctor => ctor.Comment("Empty") + static ctor => ctor.Comment("Empty") ); attributeBody @@ -35,7 +35,8 @@ static SourceText HostKitAttribute() new("generateOptions", TypeLibrary.System.Boolean), ], }, - ctor => ctor.Assignment("Name", "name").Assignment("GenerateOptions", "generateOptions") + static ctor => + ctor.Assignment("Name", "name").Assignment("GenerateOptions", "generateOptions") ); attributeBody @@ -46,7 +47,7 @@ static SourceText HostKitAttribute() { Parameters = [new("generateOptions", TypeLibrary.System.Boolean)], }, - ctor => ctor.Assignment("GenerateOptions", "generateOptions") + static ctor => ctor.Assignment("GenerateOptions", "generateOptions") ); attributeBody @@ -102,13 +103,13 @@ static SourceText ResourceKitDefinitionAttribute() IsSealed = false, }, AttributeTargets.Class, - attributeBody => + static attributeBody => { attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") .Constructor( new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute), - ctor => ctor.Comment("Empty") + static ctor => ctor.Comment("Empty") ); attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") @@ -123,7 +124,7 @@ static SourceText ResourceKitDefinitionAttribute() new("propertyName", TypeLibrary.System.String), ], }, - ctor => ctor.Assignment("Name", "name").Assignment("PropertyName", "propertyName") + static ctor => ctor.Assignment("Name", "name").Assignment("PropertyName", "propertyName") ); attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") @@ -133,7 +134,7 @@ static SourceText ResourceKitDefinitionAttribute() { Parameters = [new("name", TypeLibrary.System.String)], }, - ctor => ctor.Assignment("Name", "name") + static ctor => ctor.Assignment("Name", "name") ); attributeBody @@ -171,13 +172,13 @@ static SourceText ResourceKitDefinitionAttribute() GenericTypes = [new("TResource") { Constraints = ["class"] }], }, AttributeTargets.Class, - attributeBody => + static attributeBody => { attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") .Constructor( new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute), - ctor => ctor.Comment("Empty") + static ctor => ctor.Comment("Empty") ); attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") @@ -193,7 +194,7 @@ static SourceText ResourceKitDefinitionAttribute() ], Initializer = "base(name, propertyName)", }, - ctor => ctor.Comment("Empty") + static ctor => ctor.Comment("Empty") ); attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") @@ -204,7 +205,7 @@ static SourceText ResourceKitDefinitionAttribute() Parameters = [new("name", TypeLibrary.System.String)], Initializer = "base(name)", }, - ctor => ctor.Comment("Empty") + static ctor => ctor.Comment("Empty") ); } ); diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs index fa69be1..3c78378 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs @@ -225,7 +225,9 @@ static void GenerateBuildMethod(OutputContext context, CancellationToken cancell context.Writer.MethodCallOn(TypeLibrary.System.ArgumentNullException, "ThrowIfNull", ["builder"]).NewLine(); foreach ( - var resourceKit in context.ResourceKits.AsImmutableArray().SelectMany(r => r.Items.AsImmutableArray()) + var resourceKit in context + .ResourceKits.AsImmutableArray() + .SelectMany(static r => r.Items.AsImmutableArray()) ) { cancellationToken.ThrowIfCancellationRequested(); @@ -257,7 +259,7 @@ var resourceKit in context.ResourceKits.AsImmutableArray().SelectMany(r => r.Ite foreach ( var resourceKit in context .ResourceKits.AsImmutableArray() - .SelectMany(r => r.Items.AsImmutableArray()) + .SelectMany(static r => r.Items.AsImmutableArray()) ) { cancellationToken.ThrowIfCancellationRequested(); @@ -334,7 +336,7 @@ static void GenerateHostKitOptionsClass(OutputContext context, CancellationToken foreach ( var resourceKit in context .ResourceKits.AsImmutableArray() - .SelectMany(r => r.Items.AsImmutableArray()) + .SelectMany(static r => r.Items.AsImmutableArray()) ) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs index 4fd58ed..ec9b7fd 100644 --- a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs @@ -161,7 +161,8 @@ static class DiagnosticLibrary messageFormat: "The '{0}' property of type '{1}' is never assigned in BuildResource or ConfigureResource; resource kit properties must be populated during the build or configure lifecycle", category: Category, defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true + isEnabledByDefault: true, + description: "Execution-only concern: the resource kit will fail at runtime, not generation." ); public static readonly DiagnosticDescriptor ProjectDefinitionMismatch = new( @@ -169,8 +170,9 @@ static class DiagnosticLibrary title: "Project resource definition mismatch", messageFormat: "The '{0}' resource kit declares project '{1}' but BuildResource must add the same project via AddProject()", category: Category, - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Execution-only concern: the declared project is not registered, so the resource fails at runtime; generation is not blocked." ); public static readonly DiagnosticDescriptor ProjectResourceKitBaseMismatch = new( @@ -178,7 +180,18 @@ static class DiagnosticLibrary title: "Project resource kit base must use ProjectResource", messageFormat: "The '{0}' resource kit declares a project but its explicit base class '{1}' does not use ProjectResource", category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Execution-only concern: the explicit base cannot build the declared project resource; generation is not blocked." + ); + + public static readonly DiagnosticDescriptor AssignSetsMultiplePropertyPaths = new( + id: "SG0020", + title: "OptionsHelper.Assign action must set exactly one property path", + messageFormat: "An OptionsHelper.Assign action must set exactly one property path. Found {0} assignments: {1}. Split each into its own assignment: Assign(o => o.A = ..., o => o.B = ...).", + category: Category, defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true + isEnabledByDefault: true, + description: "Each OptionsHelper.Assign action must set exactly one property path; an action that assigns more than one property throws at runtime when the arguments are built." ); } diff --git a/src/src/SourceGeneration/Helpers/GeneratedText.cs b/src/src/SourceGeneration/Helpers/GeneratedText.cs index 9961834..e452b0b 100644 --- a/src/src/SourceGeneration/Helpers/GeneratedText.cs +++ b/src/src/SourceGeneration/Helpers/GeneratedText.cs @@ -7,7 +7,7 @@ static class GeneratedText { public static string QuoteLiteral(string value) { - var builder = new StringBuilder(value.Length + 2); + StringBuilder builder = new(value.Length + 2); builder.Append('"'); foreach (var character in value) { diff --git a/src/src/SourceGeneration/Helpers/HintNameHelper.cs b/src/src/SourceGeneration/Helpers/HintNameHelper.cs index 6557566..069473f 100644 --- a/src/src/SourceGeneration/Helpers/HintNameHelper.cs +++ b/src/src/SourceGeneration/Helpers/HintNameHelper.cs @@ -11,7 +11,7 @@ public static string ForHost(string metadataFullName) if (metadataFullName is null) throw new ArgumentNullException(nameof(metadataFullName)); var identity = metadataFullName; - var safeIdentity = new StringBuilder(identity.Length); + StringBuilder safeIdentity = new(identity.Length); foreach (var character in identity) { if (char.IsLetterOrDigit(character) || character is '.' or '_' or '-') @@ -24,7 +24,7 @@ public static string ForHost(string metadataFullName) using (var sha256 = SHA256.Create()) digest = sha256.ComputeHash(Encoding.UTF8.GetBytes(identity)); - var hash = new StringBuilder(12); + StringBuilder hash = new(12); for (var index = 0; index < 6; index++) hash.Append(digest[index].ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); return $"{safeIdentity}.AspireResourceKit.{hash}.g.cs"; diff --git a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs index b1ce5e4..f32212c 100644 --- a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs +++ b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs @@ -11,6 +11,11 @@ namespace Purview.Aspire.ResourceKit.SourceGeneration.Helpers; /// resource kit should be generated) evaluate rules through this single set of helpers so the two never /// drift apart. /// +/// +/// Rules split into two groups: generation-blocking rules (raised as errors, which stop generation via +/// ) and execution-only rules (see +/// , raised as warnings so generation always proceeds). +/// static class ResourceKitRules { /// @@ -38,22 +43,42 @@ static class ResourceKitRules ); /// - /// A neutral rule evaluation that can be converted into either a (for the + /// A neutral rule evaluation that can be converted into either a (for the /// generator's incremental model) or a Roslyn (for the analyzer). /// internal readonly record struct RuleEvaluation( DiagnosticDescriptor Descriptor, + bool IsBlocking, Location? Location, ImmutableArray MessageArgs ) { - public DiagnosticInfo ToDiagnosticInfo() => DiagnosticInfo.Create(Descriptor, Location, [.. MessageArgs]); + public ReportableDiagnostic ToDiagnosticInfo() => + ReportableDiagnostic.Create(Descriptor, IsBlocking, Location, [.. MessageArgs]); public Diagnostic ToDiagnostic() => Diagnostic.Create(Descriptor, Location ?? Location.None, [.. MessageArgs]); } + /// + /// The diagnostic IDs that report problems which prevent correct runtime execution but do not + /// prevent generation. These are raised as warnings so they never flip a GeneratorResult's + /// ShouldProcess (and therefore never halt generation via IsFatal). A resource kit with + /// an incomplete or inconsistent BuildResource/ConfigureResource wiring (a project not + /// added via AddProject<T>(), or an IResourceBuilder<T> property never + /// assigned) must still be generated so the user can complete the override rather than lose the + /// whole host kit output. + /// + public static readonly ImmutableHashSet ExecutionOnlyRuleIds = ImmutableHashSet.Create( + StringComparer.Ordinal, + DiagnosticLibrary.ResourcePropertyNeverSet.Id, + DiagnosticLibrary.ProjectDefinitionMismatch.Id, + DiagnosticLibrary.ProjectResourceKitBaseMismatch.Id + ); + public static bool IsAnalyzerOwned(DiagnosticDescriptor descriptor) => AnalyzerOwnedRuleIds.Contains(descriptor.Id); + public static bool IsExecutionOnly(DiagnosticDescriptor descriptor) => ExecutionOnlyRuleIds.Contains(descriptor.Id); + public static ImmutableArray EvaluateHostKit( INamedTypeSymbol symbol, CancellationToken cancellationToken @@ -67,7 +92,12 @@ CancellationToken cancellationToken if (!TypeHelpers.IsPartial(declaration)) { diagnostics.Add( - new(DiagnosticLibrary.ClassMustBePartial, declaration.Identifier.GetLocation(), [symbol.Name]) + new( + DiagnosticLibrary.ClassMustBePartial, + IsBlocking: true, + declaration.Identifier.GetLocation(), + [symbol.Name] + ) ); } @@ -76,6 +106,7 @@ CancellationToken cancellationToken diagnostics.Add( new( DiagnosticLibrary.NonEmptyConstructorsNotSupported, + IsBlocking: true, declaration.Identifier.GetLocation(), [symbol.Name] ) @@ -100,7 +131,12 @@ CancellationToken cancellationToken if (!TypeHelpers.IsPartial(declaration)) { diagnostics.Add( - new(DiagnosticLibrary.ClassMustBePartial, declaration.Identifier.GetLocation(), [symbol.Name]) + new( + DiagnosticLibrary.ClassMustBePartial, + IsBlocking: true, + declaration.Identifier.GetLocation(), + [symbol.Name] + ) ); } @@ -109,6 +145,7 @@ CancellationToken cancellationToken diagnostics.Add( new( DiagnosticLibrary.NonEmptyConstructorsNotSupported, + IsBlocking: true, declaration.Identifier.GetLocation(), [symbol.Name] ) @@ -137,7 +174,12 @@ CancellationToken cancellationToken if (allAttributes.Length > 1) { diagnostics.Add( - new(DiagnosticLibrary.MixedResourceDefinitionAttributesNotSupported, GetLocation(symbol), [symbol.Name]) + new( + DiagnosticLibrary.MixedResourceDefinitionAttributesNotSupported, + IsBlocking: true, + GetLocation(symbol), + [symbol.Name] + ) ); } @@ -146,6 +188,7 @@ CancellationToken cancellationToken diagnostics.Add( new( DiagnosticLibrary.GenericResourceDefinitionCannotHaveExplicitBase, + IsBlocking: true, GetLocation(symbol), [symbol.Name] ) @@ -156,6 +199,7 @@ CancellationToken cancellationToken diagnostics.Add( new( DiagnosticLibrary.NonGenericResourceDefinitionRequiresExplicitBase, + IsBlocking: true, GetLocation(symbol), [symbol.Name, TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitBase.MetadataFullName] ) @@ -164,24 +208,33 @@ CancellationToken cancellationToken if (string.IsNullOrWhiteSpace(resourceName)) { - diagnostics.Add(new(DiagnosticLibrary.ResourceNameNotDerivable, GetLocation(symbol), [symbol.Name])); + diagnostics.Add( + new(DiagnosticLibrary.ResourceNameNotDerivable, IsBlocking: true, GetLocation(symbol), [symbol.Name]) + ); } if (!TypeHelpers.IsValidIdentifier(propertyName)) { - diagnostics.Add(new(DiagnosticLibrary.InvalidPropertyName, GetLocation(symbol), [propertyName])); + diagnostics.Add( + new(DiagnosticLibrary.InvalidPropertyName, IsBlocking: true, GetLocation(symbol), [propertyName]) + ); } if (hasExplicitBaseType && !isDerivedFromExpectedBase) { diagnostics.Add( - new(DiagnosticLibrary.ResourceMustDeriveFromResourceKitBase, GetLocation(symbol), [symbol.Name]) + new( + DiagnosticLibrary.ResourceMustDeriveFromResourceKitBase, + IsBlocking: true, + GetLocation(symbol), + [symbol.Name] + ) ); } if (!isValidResourceType) { - diagnostics.Add(new(DiagnosticLibrary.NoAspireResourceFound, GetLocation(symbol), [])); + diagnostics.Add(new(DiagnosticLibrary.NoAspireResourceFound, IsBlocking: true, GetLocation(symbol), [])); } if (declaredProject is not null) @@ -197,6 +250,7 @@ CancellationToken cancellationToken diagnostics.Add( new( DiagnosticLibrary.ProjectDefinitionMismatch, + IsBlocking: false, addProjectLocation ?? GetLocation(symbol), [symbol.Name, declaredProject.Name] ) @@ -214,6 +268,7 @@ CancellationToken cancellationToken diagnostics.Add( new( DiagnosticLibrary.ProjectResourceKitBaseMismatch, + IsBlocking: false, GetLocation(symbol), [symbol.Name, baseResourceType.MetadataFullName] ) @@ -253,6 +308,7 @@ CancellationToken cancellationToken diagnostics.Add( new( DiagnosticLibrary.ResourcePropertyNeverSet, + IsBlocking: false, property.Locations.FirstOrDefault(static location => location.IsInSource), [property.Name, property.Type.ToDisplayString()] ) @@ -385,7 +441,7 @@ TypeIdentity aspireResourceType { foreach (var @interface in param.AllInterfaces) { - var t = new TypeIdentity(@interface); + TypeIdentity t = new(@interface); if (t == TypeLibrary.Aspire.Hosting.ApplicationModel.IResource) return new(param); } diff --git a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs index 17d87a7..2b07f92 100644 --- a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs @@ -65,8 +65,8 @@ static Func< static (outputContext, resourceKits, cancellationToken) => { var groupedResourceKits = resourceKits - .Where(r => !r.IsEmpty) - .GroupBy(r => + .Where(static r => !r.IsEmpty) + .GroupBy(static r => { if (r.Value.ResourceKitType.IsGlobalNamespace) return "<>"; @@ -74,8 +74,8 @@ static Func< // Use the namespace of the target type as the key for grouping return r.Value.ResourceKitType.Namespace!; }) - .OrderBy(g => g.Key, StringComparer.Ordinal) - .Select(g => new ResourceKitGroup( + .OrderBy(static g => g.Key, StringComparer.Ordinal) + .Select(static g => new ResourceKitGroup( g.Key, EquatableArray>.Create([.. g]) )) @@ -109,7 +109,7 @@ IncrementalGeneratorInitializationContext context context, TypeLibrary.Purview.Aspire.ResourceKit.GenericResourceDefinitionAttribute, transform: static (ctx, ct) => GetResourceKitModel(ctx, ct), - predicate: (s, _) => s is ClassDeclarationSyntax, + predicate: static (s, _) => s is ClassDeclarationSyntax, trackingName: GeneratorTrackingNames.GenericResourceDefinitionTargets ); @@ -120,7 +120,7 @@ IncrementalGeneratorInitializationContext context context, TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute, transform: static (ctx, ct) => GetResourceKitModel(ctx, ct), - predicate: (s, _) => s is ClassDeclarationSyntax, + predicate: static (s, _) => s is ClassDeclarationSyntax, trackingName: GeneratorTrackingNames.ResourceDefinitionTargets ); @@ -132,7 +132,7 @@ IncrementalGeneratorInitializationContext context context, TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute, transform: static (ctx, ct) => GetHostKitModel(ctx, ct), - predicate: (s, _) => s is ClassDeclarationSyntax, + predicate: static (s, _) => s is ClassDeclarationSyntax, trackingName: GeneratorTrackingNames.HostKitTargets ); diff --git a/src/src/SourceGeneration/HostKitGenerator.cs b/src/src/SourceGeneration/HostKitGenerator.cs index 4391742..d94e79d 100644 --- a/src/src/SourceGeneration/HostKitGenerator.cs +++ b/src/src/SourceGeneration/HostKitGenerator.cs @@ -12,7 +12,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { context .RegisterEmbeddedAttribute() - .RegisterPostInitializationOutput(postInitContext => + .RegisterPostInitializationOutput(static postInitContext => { foreach (var (HintName, Source) in CodeGenEmiiter.EmitAttributes()) postInitContext.AddSource(HintName, Source); @@ -24,7 +24,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput( outputProvider, - (sourceProductionContext, combined) => + static (sourceProductionContext, combined) => { var (generationModel, generationContext) = combined; if (generationContext.Settings.IsSourceGeneratorDisabled) @@ -38,10 +38,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var validResourceKits = generationModel .ResourceKits.AsImmutableArray() - .Select(m => new ResourceKitModelGroup( + .Select(static m => new ResourceKitModelGroup( m.Namespace, EquatableArray.Create([ - .. m.Items.AsImmutableArray().Where(m => m.ShouldProcess).Select(m => m.Value), + .. m + .Items.AsImmutableArray() + .Where(static m => m.ShouldProcess) + .Select(static m => m.Value), ]) )) .ToImmutableArray(); diff --git a/src/src/SourceGeneration/Models/KitGenerationModels.cs b/src/src/SourceGeneration/Models/KitGenerationModels.cs index 991b533..2da0028 100644 --- a/src/src/SourceGeneration/Models/KitGenerationModels.cs +++ b/src/src/SourceGeneration/Models/KitGenerationModels.cs @@ -17,7 +17,7 @@ sealed record ResourceKitModelGroup(string Namespace, EquatableArray> HostKits, - EquatableArray Diagnostics + EquatableArray Diagnostics ) { public bool HasHostKit => !HostKit.IsEmpty; @@ -28,26 +28,28 @@ EquatableArray Diagnostics public EquatableArray ResourceKits { get; init; } = EquatableArray.Empty; - public (bool IsFatal, EquatableArray Diagnostics) GetAllDiagnostics() + public (bool IsFatal, EquatableArray Diagnostics) GetAllDiagnostics() { var allDiagnostics = Diagnostics .Concat( HostKits .AsImmutableArray() - .SelectMany(m => m.Diagnostics) + .SelectMany(static m => m.Diagnostics) .Concat( ResourceKits .AsImmutableArray() - .SelectMany(r => r.Items.AsImmutableArray().SelectMany(d => d.Diagnostics)) + .SelectMany(static r => r.Items.AsImmutableArray().SelectMany(static d => d.Diagnostics)) ) ) .ToImmutableArray(); var isFatal = - HostKits.AsImmutableArray().Any(h => !h.ShouldProcess) - || ResourceKits.AsImmutableArray().Any(r => r.Items.AsImmutableArray().Any(d => !d.ShouldProcess)); + HostKits.AsImmutableArray().Any(static h => !h.ShouldProcess) + || ResourceKits + .AsImmutableArray() + .Any(static r => r.Items.AsImmutableArray().Any(static d => !d.ShouldProcess)); - return (isFatal, EquatableArray.Create([.. allDiagnostics])); + return (isFatal, EquatableArray.Create([.. allDiagnostics])); } } @@ -59,7 +61,7 @@ GenerationContext Context { public HostKitModel HostKit => Model.HostKit.Value; - public int ResourceKitCount => ResourceKits.AsImmutableArray().Sum(r => r.Items.Count); + public int ResourceKitCount => ResourceKits.AsImmutableArray().Sum(static r => r.Items.Count); public bool HasResourceKits => ResourceKitCount > 0; diff --git a/src/src/SourceGeneration/OptionsHelperAssignAnalyzer.cs b/src/src/SourceGeneration/OptionsHelperAssignAnalyzer.cs new file mode 100644 index 0000000..40d09e2 --- /dev/null +++ b/src/src/SourceGeneration/OptionsHelperAssignAnalyzer.cs @@ -0,0 +1,147 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Reports SG0020 when an OptionsHelper.Assign (or chained IOptionsBuilder.Assign) +/// action is a block-bodied lambda that assigns more than one property path. Each assignment action must +/// set exactly one property path, otherwise OptionsHelper throws at runtime when the arguments are +/// built. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class OptionsHelperAssignAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + [DiagnosticLibrary.AssignSetsMultiplePropertyPaths]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction( + static analysisContext => AnalyzeInvocation(analysisContext), + SyntaxKind.InvocationExpression + ); + } + + static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + if (context.Node is not InvocationExpressionSyntax invocation) + return; + + if ( + context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol + is not IMethodSymbol method + || !IsOptionsHelperAssign(method) + ) + return; + + var assignmentArguments = GetAssignmentActionArguments(invocation, method); + foreach (var (argument, lambda) in assignmentArguments) + { + if (!TryGetBlockAssignments(lambda, out var assignments) || assignments.Count < 2) + continue; + + var distinctPaths = assignments + .Select(static assignment => assignment.Left.ToString()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (distinctPaths.Length < 2) + continue; + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.AssignSetsMultiplePropertyPaths, + lambda.GetLocation(), + distinctPaths.Length, + string.Join(", ", distinctPaths) + ) + ); + } + } + + /// + /// Matches OptionsHelper.Assign<TOptions>(...), its sectionName overload, and the + /// chained IOptionsBuilder.Assign<TOptions>(...) calls by method name, containing + /// namespace, and the params Action<T>[] parameter shape. + /// + static bool IsOptionsHelperAssign(IMethodSymbol method) + { + if (method.Name != "Assign") + return false; + + if (method.ContainingNamespace?.ToDisplayString() != TypeLibraryGenerator.PurviewAspireResourceKitNamespace) + return false; + + // The method must have a params Action[] parameter, which is the last parameter. + return method.Parameters.Any(static parameter => + parameter.IsParams + && parameter.Type is IArrayTypeSymbol { ElementType: INamedTypeSymbol { Name: "Action" } elementType } + && elementType.TypeArguments.Length == 1 + ); + } + + /// + /// Returns each argument that is bound to the params Action<T>[] parameter together with + /// its lambda (or anonymous method) syntax, skipping the leading sectionName string argument and + /// arguments that pass the whole array as a single value. + /// + static IEnumerable<(ArgumentSyntax Argument, LambdaExpressionSyntax Lambda)> GetAssignmentActionArguments( + InvocationExpressionSyntax invocation, + IMethodSymbol method + ) + { + var paramsParameterIndex = method.Parameters.ToList().FindIndex(static parameter => parameter.IsParams); + if (paramsParameterIndex < 0) + yield break; + + var arguments = invocation.ArgumentList.Arguments; + for (var i = paramsParameterIndex; i < arguments.Count; i++) + { + var argument = arguments[i]; + if (argument.Expression is LambdaExpressionSyntax lambda) + yield return (argument, lambda); + } + } + + /// + /// Collects the member-assignment expressions in a block-bodied lambda (or anonymous method). Only + /// assignments whose left-hand side is a member access (for example o.X.Y) are considered. + /// + static bool TryGetBlockAssignments(LambdaExpressionSyntax lambda, out List assignments) + { + BlockSyntax? body = null; + if (lambda is SimpleLambdaExpressionSyntax simpleLambda && simpleLambda.Body is BlockSyntax simpleBlock) + body = simpleBlock; + else if ( + lambda is ParenthesizedLambdaExpressionSyntax parenthesized + && parenthesized.Body is BlockSyntax parenthesizedBlock + ) + body = parenthesizedBlock; + + if (body is null) + { + assignments = []; + return false; + } + + assignments = + [ + .. body.DescendantNodes() + .OfType() + .Where(static assignment => assignment.Left is MemberAccessExpressionSyntax), + ]; + + return true; + } +} diff --git a/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs b/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs index 9f5aab5..ac41d12 100644 --- a/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs +++ b/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs @@ -10,8 +10,8 @@ public sealed class HostResourceKitTests public async Task Build_WhenDisabledByServices_DoesNotCallBuild() { var builder = DistributedApplication.CreateBuilder(); - var hostApp = new TestHostKit(); - var resource = new TestResourceKit(hostApp, enabled: false); + TestHostKit hostApp = new(); + TestResourceKit resource = new(hostApp, enabled: false); resource.Build(builder); @@ -23,8 +23,8 @@ public async Task Build_WhenDisabledByServices_DoesNotCallBuild() public async Task Build_WhenEnabled_CallsBuildAndSetsResourceBuilder() { var builder = DistributedApplication.CreateBuilder(); - var hostApp = new TestHostKit(); - var resource = new TestResourceKit(hostApp, enabled: true); + TestHostKit hostApp = new(); + TestResourceKit resource = new(hostApp, enabled: true); resource.Build(builder); @@ -37,8 +37,8 @@ public async Task Build_WhenEnabled_CallsBuildAndSetsResourceBuilder() public async Task Configure_WhenDisabled_DoesNotCallConfigure() { var builder = DistributedApplication.CreateBuilder(); - var hostApp = new TestHostKit(); - var resource = new TestResourceKit(hostApp, enabled: false); + TestHostKit hostApp = new(); + TestResourceKit resource = new(hostApp, enabled: false); resource.Build(builder); resource.Configure(); @@ -50,8 +50,8 @@ public async Task Configure_WhenDisabled_DoesNotCallConfigure() public async Task IsResourceEnabled_WithServices_DelegatesToBuilderOnlyOverloadByDefault() { var builder = DistributedApplication.CreateBuilder(); - var hostApp = new TestHostKit(); - var resource = new DelegatingTestResourceKit(hostApp); + TestHostKit hostApp = new(); + DelegatingTestResourceKit resource = new(hostApp); resource.Build(builder); @@ -63,8 +63,8 @@ public async Task Configure_WhenResourceAddedAfterBuild_CallsConfigureOnAddedRes { // Arrange var builder = DistributedApplication.CreateBuilder(); - var hostApp = new TestHostKit(); - var resource = new TestResourceKit(hostApp, enabled: true); + TestHostKit hostApp = new(); + TestResourceKit resource = new(hostApp, enabled: true); hostApp.AddResource(resource); hostApp.Build(builder); @@ -80,8 +80,8 @@ public async Task Configure_WhenResourceAddedAfterBuild_CallsConfigureOnAddedRes public async Task AddResource_WhenSealed_ThrowsInvalidOperationException() { // Arrange - var hostApp = new TestHostKit(); - var resource = new TestResourceKit(hostApp); + TestHostKit hostApp = new(); + TestResourceKit resource = new(hostApp); hostApp.Build(IDistributedApplicationBuilder.Mock()); // Act/Assert diff --git a/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs b/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs index 1912252..87ae4c6 100644 --- a/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs +++ b/src/tests/ResourceKit.UnitTests/Models/OptionsModels.cs @@ -45,10 +45,10 @@ sealed class HostKitOptions { public RedisOptions Redis { get; set; } = new(); - public ApiOptions Api { get; set; } = new(); + public APIOptions API { get; set; } = new(); } -sealed class ApiOptions +sealed class APIOptions { public string Name { get; set; } = string.Empty; } diff --git a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs index 0b3cc6b..10cbea7 100644 --- a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs +++ b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs @@ -7,14 +7,18 @@ public sealed class OptionsHelperTests readonly string _aTestingValue = $"This is a test value - {Guid.NewGuid()}"; [Test] - public async Task For_GivenExplicitSectionName_UsesSectionOverride() + public async Task Assign_GivenExplicitSectionName_UsesSectionOverride() { // Arrange const string sectionName = "SectionNameGoesHere"; // Act var args = OptionsHelper - .ForSet(sectionName, c => c.Redis.IsEnabled = false, c => c.Redis.Name = "PIES") + .Assign( + sectionName, + static c => c.Redis.IsEnabled = false, + static c => c.Redis.Name = "PIES" + ) .Build(); // Assert @@ -24,14 +28,14 @@ public async Task For_GivenExplicitSectionName_UsesSectionOverride() } [Test] - public async Task ForSet_WithNestedClasses_GeneratesCorrectSet() + public async Task Assign_WithNestedClasses_GeneratesCorrectSet() { // Act var args = OptionsHelper - .ForSet( - c => c.EnableFeatureA = false, - c => c.MoreOptions.EnableFeatureZ = false, - c => c.MoreOptions.EvenMore.EndOfTheLine = "PIES" + .Assign( + static c => c.EnableFeatureA = false, + static c => c.MoreOptions.EnableFeatureZ = false, + static c => c.MoreOptions.EvenMore.EndOfTheLine = "PIES" ) .Build(); @@ -43,14 +47,14 @@ public async Task ForSet_WithNestedClasses_GeneratesCorrectSet() } [Test] - public async Task ForSet_WithVariables_GeneratesCorrectSet() + public async Task Assign_WithVariables_GeneratesCorrectSet() { // Arrange const bool featureAEnabled = false; // Act var args = OptionsHelper - .ForSet( + .Assign( c => c.EnableFeatureA = featureAEnabled, c => c.MoreOptions.EnableFeatureZ = false, c => c.MoreOptions.EvenMore.EndOfTheLine = _aTestingValue @@ -65,12 +69,12 @@ public async Task ForSet_WithVariables_GeneratesCorrectSet() } [Test] - public async Task For_GivenNoSectionOverride_UsesSectionNameConstValue() + public async Task Assign_GivenNoSectionOverride_UsesSectionNameConstValue() { // Arrange // Act - var args = OptionsHelper.ForSet(c => c.Redis.Name = "PIES").Build(); + var args = OptionsHelper.Assign(static c => c.Redis.Name = "PIES").Build(); // Assert await Assert.That(args.Length).IsEqualTo(1); @@ -78,15 +82,15 @@ public async Task For_GivenNoSectionOverride_UsesSectionNameConstValue() } [Test] - public async Task For_GivenNoConstSection_RemovesKnownSuffix() + public async Task Assign_GivenNoConstSection_RemovesKnownSuffix() { // Arrange // Act - var fromOptions = OptionsHelper.ForSet(c => c.Redis.Name = "PIES").Build(); - var fromSettings = OptionsHelper.ForSet(c => c.Redis.Name = "PIES").Build(); - var fromConfiguration = OptionsHelper.ForSet(c => c.Redis.Name = "PIES").Build(); - var fromConfig = OptionsHelper.ForSet(c => c.Redis.Name = "PIES").Build(); + var fromOptions = OptionsHelper.Assign(static c => c.Redis.Name = "PIES").Build(); + var fromSettings = OptionsHelper.Assign(static c => c.Redis.Name = "PIES").Build(); + var fromConfiguration = OptionsHelper.Assign(static c => c.Redis.Name = "PIES").Build(); + var fromConfig = OptionsHelper.Assign(static c => c.Redis.Name = "PIES").Build(); // Assert await Assert.That(fromOptions[0]).IsEqualTo("--Service:Redis:Name=PIES"); @@ -96,12 +100,12 @@ public async Task For_GivenNoConstSection_RemovesKnownSuffix() } [Test] - public async Task For_GivenTypeNameOnlySuffix_UsesOriginalTypeName() + public async Task Assign_GivenTypeNameOnlySuffix_UsesOriginalTypeName() { // Arrange // Act - var args = OptionsHelper.ForSet(c => c.Redis.Name = "PIES").Build(); + var args = OptionsHelper.Assign(static c => c.Redis.Name = "PIES").Build(); // Assert await Assert.That(args.Length).IsEqualTo(1); @@ -109,12 +113,12 @@ public async Task For_GivenTypeNameOnlySuffix_UsesOriginalTypeName() } [Test] - public async Task For_GivenDeepAssignment_ProducesColonSeparatedInfiniteDepthPath() + public async Task Assign_GivenDeepAssignment_ProducesColonSeparatedInfiniteDepthPath() { // Arrange // Act - var args = OptionsHelper.ForSet(c => c.Level1.Level2.Level3.Level4.Name = "PIES").Build(); + var args = OptionsHelper.Assign(static c => c.Level1.Level2.Level3.Level4.Name = "PIES").Build(); // Assert await Assert.That(args.Length).IsEqualTo(1); @@ -122,16 +126,16 @@ public async Task For_GivenDeepAssignment_ProducesColonSeparatedInfiniteDepthPat } [Test] - public async Task For_GivenThreeAssignments_UsingParams_ProducesThreeArgs() + public async Task Assign_GivenThreeAssignments_UsingParams_ProducesThreeArgs() { // Arrange // Act var args = OptionsHelper - .ForSet( - c => c.Redis.IsEnabled = false, - c => c.Redis.Name = "PIES", - c => c.Api.Name = "my-api" + .Assign( + static c => c.Redis.IsEnabled = false, + static c => c.Redis.Name = "PIES", + static c => c.API.Name = "my-api" ) .Build(); @@ -139,170 +143,131 @@ public async Task For_GivenThreeAssignments_UsingParams_ProducesThreeArgs() await Assert.That(args.Length).IsEqualTo(3); await Assert.That(args[0]).IsEqualTo("--HostKit:Redis:IsEnabled=false"); await Assert.That(args[1]).IsEqualTo("--HostKit:Redis:Name=PIES"); - await Assert.That(args[2]).IsEqualTo("--HostKit:Api:Name=my-api"); + await Assert.That(args[2]).IsEqualTo("--HostKit:API:Name=my-api"); } [Test] - public async Task For_GivenAssignmentsArray_UsesArrayAndSectionOverride() + public async Task Assign_GivenAssignmentsArray_UsesArrayAndSectionOverride() { // Arrange Action[] assignments = [ - c => c.Redis.IsEnabled = false, - c => c.Redis.Name = "PIES", - c => c.Api.Name = "my-api", + static c => c.Redis.IsEnabled = false, + static c => c.Redis.Name = "PIES", + static c => c.API.Name = "my-api", ]; // Act - var args = OptionsHelper.ForSet("CustomSection", assignments).Build(); + var args = OptionsHelper.Assign("CustomSection", assignments).Build(); // Assert await Assert.That(args.Length).IsEqualTo(3); await Assert.That(args[0]).IsEqualTo("--CustomSection:Redis:IsEnabled=false"); await Assert.That(args[1]).IsEqualTo("--CustomSection:Redis:Name=PIES"); - await Assert.That(args[2]).IsEqualTo("--CustomSection:Api:Name=my-api"); - } - - [Test] - public async Task ForOne_GivenSelectorExpression_ReturnsArgumentWithDefaultValue() - { - // Arrange - - // Act - var arg = OptionsHelper.ForOne(f => f.CurrentKey).Build()[0]; - - // Assert - await Assert.That(arg).IsEqualTo("--SampleStore:CurrentKey=default-key"); - } - - [Test] - public async Task ForOne_GivenSelectorExpressionWithValueType_ReturnsArgumentWithDefaultValue() - { - // Arrange - - // Act - var arg = OptionsHelper.ForOne(f => f.Count).Build()[0]; - - // Assert - await Assert.That(arg).IsEqualTo("--SampleStore:Count=42"); - } - - [Test] - public async Task ForOne_GivenSectionNameAndSelectorExpression_ReturnsArgumentWithOverride() - { - // Arrange - const string sectionName = "MySection"; - - // Act - var arg = OptionsHelper.ForOne(sectionName, f => f.CurrentKey).Build()[0]; - - // Assert - await Assert.That(arg).IsEqualTo("--MySection:CurrentKey=default-key"); + await Assert.That(args[2]).IsEqualTo("--CustomSection:API:Name=my-api"); } [Test] - public async Task ForOne_GivenNestedSelectorExpression_ReturnsArgumentWithNestedDefaultValue() + public async Task Assign_ChainedMultipleTimes_CollectsAllEntries() { // Arrange // Act - var arg = OptionsHelper.ForOne(f => f.Nested!.Value).Build()[0]; + var args = OptionsHelper + .Assign(static c => c.Redis.Name = "redis-a") + .Assign(static c => c.API.Name = "api-a") + .Build(); // Assert - await Assert.That(arg).IsEqualTo("--SampleStore:Nested:Value=nested-default"); + await Assert.That(args.Length).IsEqualTo(2); + await Assert.That(args[0]).IsEqualTo("--HostKit:Redis:Name=redis-a"); + await Assert.That(args[1]).IsEqualTo("--HostKit:API:Name=api-a"); } [Test] - public async Task For_GivenSelectorExpressions_ReturnsArgumentsWithDefaultValues() + public async Task Assign_GivenSingleActionSettingMultiplePaths_Throws() { // Arrange // Act - var args = OptionsHelper.For(f => f.CurrentKey, f => f.Count).Build(); + var exception = await Assert + .That(static () => + OptionsHelper + .Assign(static c => + { + c.Redis.Name = "PIES"; + c.API.Name = "api-a"; + }) + .Build() + ) + .Throws(); // Assert - await Assert.That(args.Length).IsEqualTo(2); - await Assert.That(args[0]).IsEqualTo("--SampleStore:CurrentKey=default-key"); - await Assert.That(args[1]).IsEqualTo("--SampleStore:Count=42"); + await Assert.That(exception!.Message).Contains("Found 2 candidate paths"); + await Assert.That(exception!.Message).Contains("Redis:Name"); + await Assert.That(exception!.Message).Contains("API:Name"); } [Test] - public async Task For_GivenSectionNameAndSelectorExpressions_ReturnsArgumentsWithOverride() + public async Task PathFor_GivenSimpleSelector_ReturnsMemberPath() { // Arrange - const string sectionName = "MySection"; // Act - var args = OptionsHelper.For(sectionName, f => f.CurrentKey, f => f.Count).Build(); + var path = OptionsHelper.PathFor(static f => f.CurrentKey); // Assert - await Assert.That(args.Length).IsEqualTo(2); - await Assert.That(args[0]).IsEqualTo("--MySection:CurrentKey=default-key"); - await Assert.That(args[1]).IsEqualTo("--MySection:Count=42"); + await Assert.That(path).IsEqualTo("CurrentKey"); } [Test] - public async Task For_GivenNestedSelectorExpression_ReturnsArgumentWithNestedDefaultValue() + public async Task PathFor_GivenNestedSelector_ReturnsDotSeparatedPath() { // Arrange // Act - var args = OptionsHelper.For(f => f.Nested!.Value).Build(); + var path = OptionsHelper.PathFor(static f => f.Nested.Value); // Assert - await Assert.That(args.Length).IsEqualTo(1); - await Assert.That(args[0]).IsEqualTo("--SampleStore:Nested:Value=nested-default"); + await Assert.That(path).IsEqualTo("Nested.Value"); } [Test] - public async Task ForSet_ChainedMultipleTimes_CollectsAllEntries() + public async Task PathFor_GivenNestedSelectorWithNullForgivingOperator_ReturnsDotSeparatedPath() { // Arrange // Act - var args = OptionsHelper - .ForSet(c => c.Redis.Name = "redis-a") - .ForSet(c => c.Api.Name = "api-a") - .Build(); + var path = OptionsHelper.PathFor(static f => f.Nested!.Value); // Assert - await Assert.That(args.Length).IsEqualTo(2); - await Assert.That(args[0]).IsEqualTo("--HostKit:Redis:Name=redis-a"); - await Assert.That(args[1]).IsEqualTo("--HostKit:Api:Name=api-a"); + await Assert.That(path).IsEqualTo("Nested.Value"); } [Test] - public async Task ForSetOne_ChainedWithForSet_CollectsAllEntries() + public async Task PathFor_GivenDeepSelector_ReturnsDotSeparatedPath() { // Arrange // Act - var args = OptionsHelper - .ForSetOne(c => c.Redis.Name = "redis-b") - .ForSet(c => c.Api.Name = "api-b") - .Build(); + var path = OptionsHelper.PathFor(static f => f.Level1.Level2.Level3.Level4.Name); // Assert - await Assert.That(args.Length).IsEqualTo(2); - await Assert.That(args[0]).IsEqualTo("--HostKit:Redis:Name=redis-b"); - await Assert.That(args[1]).IsEqualTo("--HostKit:Api:Name=api-b"); + await Assert.That(path).IsEqualTo("Level1.Level2.Level3.Level4.Name"); } [Test] - public async Task For_ChainedWithForSet_CollectsAllEntries() + public async Task PathFor_GivenInvalidSelector_Throws() { // Arrange // Act - var args = OptionsHelper - .For(f => f.CurrentKey) - .ForSet(c => c.Redis.Name = "redis-c") - .Build(); + var exception = await Assert + .That(static () => OptionsHelper.PathFor(static f => f.Count + 1)) + .Throws(); // Assert - await Assert.That(args.Length).IsEqualTo(2); - await Assert.That(args[0]).IsEqualTo("--SampleStore:CurrentKey=default-key"); - await Assert.That(args[1]).IsEqualTo("--HostKit:Redis:Name=redis-c"); + await Assert.That(exception!.Message).Contains("member access expression"); } [Test] @@ -311,7 +276,10 @@ public async Task AsEnvironmentVariables_GivenEntries_ReturnsDictionaryWithDoubl // Arrange // Act - var envVars = OptionsHelper.ForSet(c => c.Redis.Name = "PIES").AsEnvironmentVariables().Build(); + var envVars = OptionsHelper + .Assign(static c => c.Redis.Name = "PIES") + .AsEnvironmentVariables() + .Build(); // Assert await Assert.That(envVars).ContainsKey("HostKit__Redis__Name"); @@ -325,10 +293,10 @@ public async Task AsEnvironmentVariables_GivenMultipleEntries_ReturnsDictionaryW // Act var envVars = OptionsHelper - .ForSet( - c => c.Redis.IsEnabled = false, - c => c.Redis.Name = "PIES", - c => c.Api.Name = "my-api" + .Assign( + static c => c.Redis.IsEnabled = false, + static c => c.Redis.Name = "PIES", + static c => c.API.Name = "my-api" ) .AsEnvironmentVariables() .Build(); @@ -337,7 +305,7 @@ public async Task AsEnvironmentVariables_GivenMultipleEntries_ReturnsDictionaryW await Assert.That(envVars.Count).IsEqualTo(3); await Assert.That(envVars["HostKit__Redis__IsEnabled"]).IsEqualTo("false"); await Assert.That(envVars["HostKit__Redis__Name"]).IsEqualTo("PIES"); - await Assert.That(envVars["HostKit__Api__Name"]).IsEqualTo("my-api"); + await Assert.That(envVars["HostKit__API__Name"]).IsEqualTo("my-api"); } [Test] @@ -348,7 +316,7 @@ public async Task AsEnvironmentVariables_GivenExplicitSectionName_ReturnsDiction // Act var envVars = OptionsHelper - .ForSet(sectionName, c => c.Redis.Name = "PIES") + .Assign(sectionName, static c => c.Redis.Name = "PIES") .AsEnvironmentVariables() .Build(); diff --git a/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs b/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs index 4c57873..b96ba7c 100644 --- a/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs @@ -216,7 +216,7 @@ static async Task AssertMessagesRenderAsync(DriverRunResult result) { var diagnostics = result .DriverResult.Diagnostics.Concat(result.AnalyzerResult?.Diagnostics ?? []) - .Where(diagnostic => diagnostic.Location.SourceTree is not null) + .Where(static diagnostic => diagnostic.Location.SourceTree is not null) .ToArray(); await Assert.That(diagnostics).IsNotEmpty(); diff --git a/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs b/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs index 1429234..3d6577e 100644 --- a/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs @@ -374,8 +374,8 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var types = assembly.GetExportedTypes(); - var hostKitType = await Assert.That(types).HasSingleItem(m => m.Name == "TestingHostKit"); - var redisResourceKitType = await Assert.That(types).HasSingleItem(m => m.Name == "RedisResourceKit"); + var hostKitType = await Assert.That(types).HasSingleItem(static m => m.Name == "TestingHostKit"); + var redisResourceKitType = await Assert.That(types).HasSingleItem(static m => m.Name == "RedisResourceKit"); await Assert.That(hostKitType.Namespace).IsEqualTo("Testing.Host"); await Assert.That(redisResourceKitType.Namespace).IsEqualTo("Testing.Resources"); @@ -443,8 +443,8 @@ CancellationToken cancellationToken var result = await GenerateAsync(sources, cancellationToken); var types = result.CompilationResult.Assembly!.GetExportedTypes(); - var globalHostKitType = types.SingleOrDefault(m => m.Name == globalHostKitTypeName); - var redisResourceKitType = types.SingleOrDefault(m => m.Name == redisResourceKitTypeName); + var globalHostKitType = types.SingleOrDefault(static m => m.Name == globalHostKitTypeName); + var redisResourceKitType = types.SingleOrDefault(static m => m.Name == redisResourceKitTypeName); // Assert await Assert.That(globalHostKitType).IsNotNull(); diff --git a/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs b/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs index ef86ce1..f839c37 100644 --- a/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs @@ -115,7 +115,9 @@ CancellationToken cancellationToken static string FindHostKitHintName(GeneratorDriverRunResult runResult) => runResult .Results[0] - .GeneratedSources.First(source => source.HintName.Contains("AspireResourceKit.", StringComparison.Ordinal)) + .GeneratedSources.First(static source => + source.HintName.Contains("AspireResourceKit.", StringComparison.Ordinal) + ) .HintName; static string GetGeneratedSource(GeneratorDriverRunResult runResult, string hintName) => @@ -124,6 +126,6 @@ static string GetGeneratedSource(GeneratorDriverRunResult runResult, string hint static IncrementalStepRunReason GetOutputReason(GeneratorDriverRunResult runResult) => // There is a single RegisterSourceOutput step; the host kit source is its only output. runResult.Results[0].TrackedOutputSteps.TryGetValue("SourceOutput", out var steps) - ? steps.SelectMany(step => step.Outputs).Select(output => output.Reason).FirstOrDefault() + ? steps.SelectMany(static step => step.Outputs).Select(static output => output.Reason).FirstOrDefault() : IncrementalStepRunReason.New; } diff --git a/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs b/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs new file mode 100644 index 0000000..3e2d195 --- /dev/null +++ b/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs @@ -0,0 +1,216 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Verifies that SG0020 is reported by when an +/// OptionsHelper.Assign (or chained IOptionsBuilder.Assign) action assigns more than one +/// property path, and is not reported for the valid single-assignment forms. +/// +public class OptionsHelperAssignAnalyzerTests : ResourceKitSourceGeneratorTestBase +{ + const string OptionsSource = """ + public class Options + { + public RedisOptions Redis { get; set; } = new(); + public RedisOptions API { get; set; } = new(); + } + + public class RedisOptions + { + public string Name { get; set; } = ""; + } + """; + + static ResourceKitSourceGeneratorTestOptions CreateOptions() + { + ResourceKitSourceGeneratorTestOptions baseOptions = new(); + return baseOptions with + { + AnalyzerTypes = [typeof(OptionsHelperAssignAnalyzer)], + AdditionalAssemblyTypes = baseOptions.AdditionalAssemblyTypes.Add(typeof(OptionsHelper)), + }; + } + + static string BuildSource(string callerSource) => "namespace Testing;\n" + OptionsSource + "\n" + callerSource; + + [Test] + public async Task Generate_GivenBlockLambdaWithTwoAssignments_ReportsAssignSetsMultiplePropertyPaths( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign(o => + { + o.Redis.Name = "a"; + o.API.Name = "b"; + }).Build(); + } + """; + + // Act + var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); + } + + [Test] + public async Task Generate_GivenBlockLambdaWithThreeAssignments_ReportsAssignSetsMultiplePropertyPaths( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign(o => + { + o.Redis.Name = "a"; + o.Redis.Name = "b"; + o.API.Name = "c"; + }).Build(); + } + """; + + // Act + var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); + } + + [Test] + public async Task Generate_GivenExpressionLambda_DoesNotReportAssignSetsMultiplePropertyPaths( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign(o => o.Redis.Name = "a").Build(); + } + """; + + // Act + var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); + } + + [Test] + public async Task Generate_GivenSingleStatementBlock_DoesNotReportAssignSetsMultiplePropertyPaths( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign(o => + { + o.Redis.Name = "a"; + }).Build(); + } + """; + + // Act + var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); + } + + [Test] + public async Task Generate_GivenSectionNameOverloadWithBlockLambda_ReportsAssignSetsMultiplePropertyPaths( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign("Section", o => + { + o.Redis.Name = "a"; + o.API.Name = "b"; + }).Build(); + } + """; + + // Act + var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); + } + + [Test] + public async Task Generate_GivenChainedBuilderAssignWithBlockLambda_ReportsAssignSetsMultiplePropertyPaths( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign(o => o.Redis.Name = "a") + .Assign(o => + { + o.Redis.Name = "b"; + o.API.Name = "c"; + }) + .Build(); + } + """; + + // Act + var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); + } + + [Test] + public async Task Generate_GivenUnrelatedAssignMethod_DoesNotReportAssignSetsMultiplePropertyPaths( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Other + { + public void Assign(System.Action action) => action(new Options()); + } + + class Caller + { + void Run() + { + new Other().Assign(o => + { + o.Redis.Name = "a"; + o.API.Name = "b"; + }); + } + } + """; + + // Act + var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); + } +} diff --git a/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignCodeFixTests.cs b/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignCodeFixTests.cs new file mode 100644 index 0000000..2d50c4c --- /dev/null +++ b/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignCodeFixTests.cs @@ -0,0 +1,104 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Verifies that splits a block-bodied +/// OptionsHelper.Assign action assigning multiple property paths into one assignment per argument. +/// +public sealed record OptionsHelperCodeFixTestOptions : CodeFixTestOptions +{ + public OptionsHelperCodeFixTestOptions() + { + AdditionalAssemblyTypes = [typeof(OptionsHelper)]; + } +} + +public class OptionsHelperAssignCodeFixTests + : TUnitCodeFixTestBase< + OptionsHelperAssignAnalyzer, + OptionsHelperAssignCodeFixProvider, + OptionsHelperCodeFixTestOptions + > +{ + const string OptionsSource = """ + public class Options + { + public RedisOptions Redis { get; set; } = new(); + public RedisOptions API { get; set; } = new(); + } + + public class RedisOptions + { + public string Name { get; set; } = ""; + } + """; + + static string BuildSource(string callerSource) => "namespace Testing;\n" + OptionsSource + "\n" + callerSource; + + static string GetFixedCode(CodeFixTestResult result) => result.FixedCode().Trees.First().GetText().ToString(); + + [Test] + public async Task ApplyCodeFix_GivenBlockLambdaWithTwoAssignments_SplitsIntoSeparateAssignments( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign(o => + { + o.Redis.Name = "a"; + o.API.Name = "b"; + }).Build(); + } + """; + + // Act + var result = await ApplyCodeFixAsync( + BuildSource(callerSource), + new OptionsHelperCodeFixTestOptions(), + cancellationToken + ); + + // Assert + var fixedCode = GetFixedCode(result); + await Assert.That(fixedCode).Contains("o => o.Redis.Name = \"a\", o => o.API.Name = \"b\""); + await Assert.That(fixedCode).DoesNotContain("o.Redis.Name = \"a\";"); + } + + [Test] + public async Task ApplyCodeFix_GivenBlockLambdaWithThreeAssignments_SplitsIntoSeparateAssignments( + CancellationToken cancellationToken + ) + { + // Arrange + const string callerSource = """ + class Caller + { + string[] Build() => + Purview.Aspire.ResourceKit.OptionsHelper.Assign(o => + { + o.Redis.Name = "a"; + o.Redis.Name = "b"; + o.API.Name = "c"; + }).Build(); + } + """; + + // Act + var result = await ApplyCodeFixAsync( + BuildSource(callerSource), + new OptionsHelperCodeFixTestOptions(), + cancellationToken + ); + + // Assert + var fixedCode = GetFixedCode(result); + await Assert + .That(fixedCode) + .Contains("o => o.Redis.Name = \"a\", o => o.Redis.Name = \"b\", o => o.API.Name = \"c\""); + } +} diff --git a/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs b/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs index 1eb74bf..2b3d843 100644 --- a/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs @@ -195,6 +195,52 @@ protected override IResourceBuilder BuildResource(IDistributedA await Assert.That(result).HasNoErrorDiagnostics(); } + [Test] + public async Task Generate_GivenProjectResourceKitWithoutAddProject_StillGeneratesKit( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Projects + { + public class Example_Service : global::Aspire.Hosting.IProjectMetadata + { + public string ProjectPath => ""; + public bool SuppressBuild => true; + } + } + + namespace Testing + { + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + sealed partial class ApiKit + { + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + throw new global::System.NotImplementedException(); + } + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert — SG0018 is an execution-only warning, so it is reported but generation still proceeds. + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ProjectDefinitionMismatch); + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains( + $"partial class ApiKit : {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitBase}<{TypeLibrary.Aspire.Hosting.ApplicationModel.ProjectResource}>" + ); + await Assert.That(generated).Contains("AddAspireResourceKit"); + } + protected override ResourceKitSourceGeneratorTestOptions OnBeforeRun( IEnumerable sources, ResourceKitSourceGeneratorTestOptions options, diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs index 83cb413..7e6d6f9 100644 --- a/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs @@ -45,10 +45,10 @@ public class NotAResourceKit // Assert var suppressed = diagnostics - .Where(diagnostic => diagnostic.Id == "CS8618" && diagnostic.IsSuppressed) + .Where(static diagnostic => diagnostic.Id == "CS8618" && diagnostic.IsSuppressed) .ToArray(); var notSuppressed = diagnostics - .Where(diagnostic => diagnostic.Id == "CS8618" && !diagnostic.IsSuppressed) + .Where(static diagnostic => diagnostic.Id == "CS8618" && !diagnostic.IsSuppressed) .ToArray(); await Assert.That(suppressed).IsNotEmpty(); @@ -89,7 +89,9 @@ protected override IResourceBuilder BuildResource(IDistri // Assert var cs8618InKit = diagnostics - .Where(diagnostic => diagnostic.Id == "CS8618" && GetEnclosingTypeName(diagnostic) == "RedisResourceKit") + .Where(static diagnostic => + diagnostic.Id == "CS8618" && GetEnclosingTypeName(diagnostic) == "RedisResourceKit" + ) .ToArray(); await Assert.That(cs8618InKit).IsEmpty(); } @@ -132,7 +134,7 @@ protected override IResourceBuilder BuildResource(IDistributedA // Assert var suppressedInKit = diagnostics - .Where(diagnostic => + .Where(static diagnostic => diagnostic.Id == "CS8618" && diagnostic.IsSuppressed && GetEnclosingTypeName(diagnostic) == "ApiKit" ) .ToArray(); diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs index 74c256f..e3c305e 100644 --- a/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs @@ -163,6 +163,44 @@ protected override IResourceBuilder BuildResource(IDistri await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); } + [Test] + public async Task Generate_GivenResourceBuilderPropertyNeverSet_StillGeneratesKit( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + sealed partial class RedisResourceKit + { + public IResourceBuilder Cache { get; private set; } + + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + throw new global::System.NotImplementedException(); + } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert — SG0017 is an execution-only warning, so it is reported but generation still proceeds. + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains( + $"partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitBase}<{TestingTypeLibrary.Purview.Aspire.ResourceKit.DefaultAspireResource}>" + ); + await Assert.That(generated).Contains("AddAspireResourceKit"); + } + protected override ResourceKitSourceGeneratorTestOptions OnBeforeRun( IEnumerable sources, ResourceKitSourceGeneratorTestOptions options, diff --git a/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj b/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj index 575322b..111d55a 100644 --- a/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj +++ b/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj @@ -21,5 +21,10 @@ PrivateAssets="all" ReferenceOutputAssembly="true" /> + diff --git a/src/tests/SourceGeneration.UnitTests/ExecutionOnlyRuleSeverityTests.cs b/src/tests/SourceGeneration.UnitTests/ExecutionOnlyRuleSeverityTests.cs new file mode 100644 index 0000000..fd1013f --- /dev/null +++ b/src/tests/SourceGeneration.UnitTests/ExecutionOnlyRuleSeverityTests.cs @@ -0,0 +1,69 @@ +using Microsoft.CodeAnalysis; +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Locks in the contract that execution-only rules (SG0017/SG0018/SG0019) never block generation. +/// These report problems that prevent runtime execution, not generation, so they must stay non-Error +/// severity (which keeps ShouldProcess/IsFatal from halting generation) and remain +/// analyzer-owned so the analyzer reports them once. +/// +public sealed class ExecutionOnlyRuleSeverityTests +{ + [Test] + public async Task ExecutionOnlyRules_AreNotErrorSeverity_SoGenerationIsNeverBlocked() + { + // Arrange + var descriptors = GetExecutionOnlyDescriptors(); + + // Act + var errorSeverityRules = descriptors.Where(static d => d.DefaultSeverity == DiagnosticSeverity.Error).ToArray(); + + // Assert + await Assert.That(errorSeverityRules).IsEmpty(); + } + + [Test] + public async Task ExecutionOnlyRules_AreRecognizedByIsExecutionOnly() + { + // Arrange + var descriptors = GetExecutionOnlyDescriptors(); + + // Act + var notRecognized = descriptors.Where(static d => !ResourceKitRules.IsExecutionOnly(d)).ToArray(); + + // Assert + await Assert.That(notRecognized).IsEmpty(); + } + + [Test] + public async Task ExecutionOnlyRules_AreAnalyzerOwned_SoAreReportedExactlyOnce() + { + // Arrange + var descriptors = GetExecutionOnlyDescriptors(); + + // Act + var notAnalyzerOwned = descriptors.Where(static d => !ResourceKitRules.IsAnalyzerOwned(d)).ToArray(); + + // Assert + await Assert.That(notAnalyzerOwned).IsEmpty(); + } + + static IEnumerable GetExecutionOnlyDescriptors() + { + var descriptorsById = typeof(DiagnosticLibrary) + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Where(static field => field.FieldType == typeof(DiagnosticDescriptor)) + .Select(static field => (DiagnosticDescriptor)field.GetValue(null)!) + .ToDictionary(static d => d.Id, StringComparer.Ordinal); + + return ResourceKitRules.ExecutionOnlyRuleIds.Select(id => + descriptorsById.TryGetValue(id, out var descriptor) + ? descriptor + : throw new InvalidOperationException( + $"Execution-only rule '{id}' has no matching descriptor in {nameof(DiagnosticLibrary)}." + ) + ); + } +} From 4d07b6577d1988cdd8d0c42bed567395c52735c0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 12 Sep 2026 15:03:30 +0100 Subject: [PATCH 2/2] chore: linting fix --- Directory.Packages.props | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a756184..44d7ffb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,9 +23,15 @@ - + - +