From 46015ad4041744ae6bda17a3b156dcc431248833 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 19 May 2026 03:05:31 -0300 Subject: [PATCH] AddChatClients migration analyzer and code fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AddChatClients` was removed from `Devlooped.Extensions.AI` in favor of: - `AddAIClients` — configuration-driven client registration - `ConfigureChatClientDefaults` — default pipelines (global or per configuration section) --- Extensions.AI.slnx | 2 + docs/DEAI001.md | 242 +++++++++++++++++ .../CodeAnalysisTest.cs | 256 ++++++++++++++++++ .../Extensions.CodeAnalysis.Tests.csproj | 31 +++ .../RoslynTestHarness.cs | 93 +++++++ .../AddChatClientsAnalyzer.cs | 66 +++++ .../AddChatClientsInvocationHelper.cs | 56 ++++ .../AnalyzerReleases.Shipped.md | 6 + .../AnalyzerReleases.Unshipped.md | 6 + .../ConfigureCallbackMigrator.cs | 212 +++++++++++++++ .../ConfigureCallbackSyntax.cs | 53 ++++ .../DiagnosticDescriptors.cs | 24 ++ src/Extensions.CodeAnalysis/DiagnosticIds.cs | 7 + .../Extensions.CodeAnalysis.csproj | 7 +- .../Properties/launchSettings.json | 8 + .../AddChatClientsCodeFixProvider.cs | 180 ++++++++++++ .../Extensions.Codefix.csproj | 21 ++ src/Extensions/Extensions.csproj | 3 +- src/Extensions/ObsoleteExtensions.cs | 4 +- src/Extensions/SKILL.md | 6 +- src/Tests/ConfigurableClientTests.cs | 13 + src/Tests/Tests.csproj | 3 +- 22 files changed, 1293 insertions(+), 6 deletions(-) create mode 100644 docs/DEAI001.md create mode 100644 src/Extensions.CodeAnalysis.Tests/CodeAnalysisTest.cs create mode 100644 src/Extensions.CodeAnalysis.Tests/Extensions.CodeAnalysis.Tests.csproj create mode 100644 src/Extensions.CodeAnalysis.Tests/RoslynTestHarness.cs create mode 100644 src/Extensions.CodeAnalysis/AddChatClientsAnalyzer.cs create mode 100644 src/Extensions.CodeAnalysis/AddChatClientsInvocationHelper.cs create mode 100644 src/Extensions.CodeAnalysis/AnalyzerReleases.Shipped.md create mode 100644 src/Extensions.CodeAnalysis/AnalyzerReleases.Unshipped.md create mode 100644 src/Extensions.CodeAnalysis/ConfigureCallbackMigrator.cs create mode 100644 src/Extensions.CodeAnalysis/ConfigureCallbackSyntax.cs create mode 100644 src/Extensions.CodeAnalysis/DiagnosticDescriptors.cs create mode 100644 src/Extensions.CodeAnalysis/DiagnosticIds.cs create mode 100644 src/Extensions.CodeAnalysis/Properties/launchSettings.json create mode 100644 src/Extensions.Codefix/AddChatClientsCodeFixProvider.cs create mode 100644 src/Extensions.Codefix/Extensions.Codefix.csproj diff --git a/Extensions.AI.slnx b/Extensions.AI.slnx index a55a983..a1edbab 100644 --- a/Extensions.AI.slnx +++ b/Extensions.AI.slnx @@ -5,6 +5,8 @@ + + diff --git a/docs/DEAI001.md b/docs/DEAI001.md new file mode 100644 index 0000000..4d8e119 --- /dev/null +++ b/docs/DEAI001.md @@ -0,0 +1,242 @@ +# DEAI001 — AddChatClients migration analyzer and code fix + +This document describes the Roslyn analyzer and code fix that replace agent-only migration guidance in [`src/Extensions/SKILL.md`](../src/Extensions/SKILL.md) with deterministic IDE and build-time support. + +## Background + +`AddChatClients` was removed from `Devlooped.Extensions.AI` in favor of: + +- `AddAIClients` — configuration-driven client registration +- `ConfigureChatClientDefaults` — default pipelines (global or per configuration section) + +The old API accepted an inline `configure: (name, b) => ...` callback. The new API has no direct equivalent; that logic must move to one or more `ConfigureChatClientDefaults` calls **before** `AddAIClients`. Dropping the callback silently changes runtime behavior. + +Previously, migration was documented only in `SKILL.md` for AI agents. This work adds a compiler analyzer, a code fix, and tests so developers get errors, warnings, and one-click fixes in the IDE. + +## Diagnostics + +| ID | Severity | When | +|----|----------|------| +| **DEAI001** | Error | Any invocation of `ConfigurableChatClientExtensions.AddChatClients` | +| **DEAI002** | Warning | A `configure:` argument is present but cannot be migrated safely by the code fix | + +**Diagnostic ownership:** DEAI001 is reported only by the analyzer, not by the compiler `Obsolete` attribute. Obsolete stubs remain for API discoverability but use non-error obsolete messages so diagnostics are not duplicated. + +### DEAI001 + +- **Title:** AddChatClients was removed +- **Message:** AddChatClients was removed; use AddAIClients and ConfigureChatClientDefaults instead + +### DEAI002 + +- **Title:** Configure callback cannot be migrated automatically +- **Message:** The configure callback cannot be migrated automatically; convert it manually to ConfigureChatClientDefaults call(s) before AddAIClients + +Emitted on the `configure` lambda when tiered analysis (below) determines the code fix cannot produce a safe equivalent. Follow [`SKILL.md`](../src/Extensions/SKILL.md) or agent guidance for manual conversion. + +## Architecture + +```mermaid +flowchart LR + subgraph consumer [Consuming project] + CallSite["AddChatClients(...)"] + end + subgraph analysis [Extensions.CodeAnalysis] + Analyzer["AddChatClientsAnalyzer"] + Migrator["ConfigureCallbackMigrator"] + DEAI001["DEAI001 Error"] + DEAI002["DEAI002 Warning"] + end + subgraph fix [Extensions.Codefix] + CodeFix["AddChatClientsCodeFixProvider"] + end + CallSite --> Analyzer + Analyzer --> Migrator + Analyzer --> DEAI001 + Migrator --> DEAI002 + DEAI001 --> CodeFix + CodeFix --> Migrated["ConfigureChatClientDefaults + AddAIClients"] +``` + +## Projects and packaging + +| Project | Role | Pack path | +|---------|------|-----------| +| [`src/Extensions.CodeAnalysis`](../src/Extensions.CodeAnalysis) | `DiagnosticAnalyzer`, descriptors, `ConfigureCallbackMigrator` | `analyzers/dotnet/roslyn5.0/cs/Extensions.CodeAnalysis.dll` | +| [`src/Extensions.Codefix`](../src/Extensions.Codefix) | `CodeFixProvider` — "Migrate to AddAIClients" | `analyzers/dotnet/roslyn5.0/cs/Extensions.Codefix.dll` | +| [`src/Extensions`](../src/Extensions) | Main package; references both as analyzers | Bundles both DLLs into `Devlooped.Extensions.AI` NuGet package | +| [`src/Extensions.CodeAnalysis.Tests`](../src/Extensions.CodeAnalysis.Tests) | Analyzer and code fix tests | Not shipped | + +Both analyzer assemblies target **netstandard2.0**, use **Microsoft.CodeAnalysis.CSharp 5.0.0**, and set `IsRoslynComponent=true` with `PackFolder=analyzers/dotnet/roslyn5.0/cs`. + +The main package references analyzers with: + +```xml + + +``` + +Consumers of `Devlooped.Extensions.AI` receive analyzer and code fix support automatically; no separate analyzer package is required. + +## Analyzer implementation + +**Type:** `AddChatClientsAnalyzer` in namespace `Devlooped.Extensions.AI.CodeAnalysis` + +**Registration:** `OperationKind.Invocation` + +**Detection:** Method name `AddChatClients` on containing type `ConfigurableChatClientExtensions` in namespace `Microsoft.Extensions.DependencyInjection` (matches reduced and unreduced extension method symbols). + +**Configure argument resolution:** Uses `IInvocationOperation.Arguments` and parameter names — not raw argument index — so positional calls like `AddChatClients(configuration, (name, b) => ..., useDefaultProviders: false)` map correctly (reduced extension methods omit the `this` parameter from `IMethodSymbol.Parameters`). + +**Flow:** + +1. Report DEAI001 on the invocation. +2. If there is no `configure` argument, stop. +3. Determine overload via first parameter type name (`IServiceCollection` vs host builder). +4. Run `ConfigureCallbackMigrator.CanMigrate`; if false, report DEAI002 on the configure argument. + +**Key files:** + +- [`DiagnosticIds.cs`](../src/Extensions.CodeAnalysis/DiagnosticIds.cs) — `DEAI001`, `DEAI002` +- [`DiagnosticDescriptors.cs`](../src/Extensions.CodeAnalysis/DiagnosticDescriptors.cs) +- [`AddChatClientsInvocationHelper.cs`](../src/Extensions.CodeAnalysis/AddChatClientsInvocationHelper.cs) — configure / non-configure argument resolution +- [`AddChatClientsAnalyzer.cs`](../src/Extensions.CodeAnalysis/AddChatClientsAnalyzer.cs) +- [`AnalyzerReleases.Unshipped.md`](../src/Extensions.CodeAnalysis/AnalyzerReleases.Unshipped.md) — release tracking for new rules + +## Configure callback migration (tiered) + +Shared logic lives in [`ConfigureCallbackMigrator.cs`](../src/Extensions.CodeAnalysis/ConfigureCallbackMigrator.cs). The analyzer uses it for DEAI002; the code fix uses `TryAnalyze` to build a `MigrationPlan`. + +### Supported patterns (code fix applies) + +| Tier | Pattern | Result | +|------|---------|--------| +| 1 | No `configure` | `AddAIClients(...)` with other arguments preserved | +| 2 | Expression-bodied: `(name, b) => b.Use...()` | `ConfigureChatClientDefaults(b => ...)` then `AddAIClients` | +| 3 | Block with only unconditional `b.*` statements | Single global `ConfigureChatClientDefaults` | +| 4 | Block with `if (name == "Section:Path")` or `name.Equals("...", ...)` | Global defaults + `ConfigureChatClientDefaults("Section:Path", b => ...)` per branch | + +Requirements for the lambda: + +- Parenthesized lambda with exactly two parameters `(name, b)`. +- Expression body must use the builder parameter. +- Block body: only expression statements that use `b`, plus optional section `if` branches as above. +- Section path must be a compile-time string constant (equality or `string.Equals`). + +### Unsupported patterns (DEAI002, no code fix) + +Examples: + +- `switch` on `name` +- Non-constant section keys +- `else` branches on section `if` +- Logic that does not map to unconditional globals + per-section defaults +- Non–parenthesized lambdas (e.g. simple lambda with one parameter) + +For these, migrate manually using examples in [`SKILL.md`](../src/Extensions/SKILL.md). + +## Code fix implementation + +**Type:** `AddChatClientsCodeFixProvider` in namespace `Devlooped.Extensions.AI.CodeFix` + +- **Fixable IDs:** `DEAI001` only +- **Title:** Migrate to AddAIClients +- **Fix all:** Batch fixer supported + +**Behavior:** + +1. Skip registration when `configure` is present and `TryAnalyze` returns null (DEAI002 case). +2. For migratable callbacks: chain `ConfigureChatClientDefaults` on the same receiver (builder or `IServiceCollection`), then `AddAIClients`. +3. Copy all invocation arguments except `configure` (`configuration`, `prefix`, `useDefaultProviders`) into `AddAIClients`. +4. Service collection vs host builder is detected by receiver type (`IServiceCollection`). + +**Key file:** [`AddChatClientsCodeFixProvider.cs`](../src/Extensions.Codefix/AddChatClientsCodeFixProvider.cs) + +## API migration reference + +### Before (removed) + +```csharp +builder.AddChatClients(); +builder.AddChatClients(configure: (name, b) => b.UseLogging().UseOpenTelemetry()); +builder.AddChatClients(prefix: "ai:clients", useDefaultProviders: true); + +services.AddChatClients(configuration); +services.AddChatClients(configuration, configure: (name, b) => b.UseLogging()); +``` + +### After (current) + +```csharp +builder.AddAIClients(); +builder.AddAIClients(prefix: "ai:clients", useDefaultProviders: true); + +services.AddAIClients(configuration); +services.AddAIClients(configuration, prefix: "ai:clients", useDefaultProviders: true); +``` + +### Configure callback → ConfigureChatClientDefaults + +```csharp +// Before +builder.AddChatClients(configure: (name, b) => +{ + b.UseLogging(); + if (name == "AI:Clients:Grok") + b.UseRateLimiting(); +}); + +// After (typical code fix output) +builder + .ConfigureChatClientDefaults(b => b.UseLogging()) + .ConfigureChatClientDefaults("AI:Clients:Grok", b => b.UseRateLimiting()) + .AddAIClients(); +``` + +Section paths use `:` as the separator and are matched case-insensitively against the exact configuration section path (no parent inheritance). + +## Obsolete stubs + +[`ObsoleteExtensions.cs`](../src/Extensions/ObsoleteExtensions.cs) still defines `AddChatClients` overloads that throw `NotSupportedException` at runtime, with **non-error** `[Obsolete]` text pointing to DEAI001. Build failures come from the analyzer, not from `Obsolete(..., error: true, DiagnosticId = "DEAI001")`. + +## Developer workflow + +1. **Build** — DEAI001 appears on every `AddChatClients` call site. +2. **Light bulb / Ctrl+.** — Choose **Migrate to AddAIClients** when offered (DEAI001 without DEAI002). +3. **DEAI002** — Review SKILL.md or agent guidance; convert `configure` manually, then apply the simple fix or edit by hand. +4. **Agents** — `SKILL.md` remains in the package (`skills/devlooped.extensions.ai/SKILL.md`) and is copied to `.agents/skills/` via MSBuild targets for repos that opt in. + +## Tests + +[`src/Extensions.CodeAnalysis.Tests`](../src/Extensions.CodeAnalysis.Tests) uses a small Roslyn harness (`RoslynTestHarness`) with `Basic.Reference.Assemblies.Net80` and references to `Devlooped.Extensions.AI` and hosting/AI assemblies. + +| Test | Verifies | +|------|----------| +| `AddChatClients_reports_DEAI001` | Analyzer emits DEAI001 | +| `Complex_configure_reports_DEAI002` | Switch-based configure → DEAI001 + DEAI002 | +| `Builder_without_configure_migrates_to_AddAIClients` | Tier 1 code fix | +| `Services_with_prefix_migrates_and_preserves_args` | Tier 1 + argument preservation | +| `Configure_expression_body_migrates_to_global_defaults` | Tier 2/3 code fix | +| `Configure_with_section_branch_migrates` | Tier 4 code fix | +| `Complex_configure_offers_no_code_fix` | No fix when migrator rejects lambda | + +Run: + +```bash +dotnet test src/Extensions.CodeAnalysis.Tests +``` + +The main [`Tests`](../src/Tests) project does **not** suppress `DEAI001`, so intentional `AddChatClients` usage (for example `ConfigurableClientTests.Migrate`) surfaces the analyzer and code fix in the IDE. + +## Solution layout + +Projects added or updated in `Extensions.AI.slnx`: + +- `src/Extensions.CodeAnalysis` +- `src/Extensions.Codefix` +- `src/Extensions.CodeAnalysis.Tests` + +## Related documentation + +- [`src/Extensions/SKILL.md`](../src/Extensions/SKILL.md) — Agent-oriented migration guide and examples +- [`AGENTS.md`](../AGENTS.md) — Repository notes on `AddClients` / `AddAIClients` registration model diff --git a/src/Extensions.CodeAnalysis.Tests/CodeAnalysisTest.cs b/src/Extensions.CodeAnalysis.Tests/CodeAnalysisTest.cs new file mode 100644 index 0000000..ece7226 --- /dev/null +++ b/src/Extensions.CodeAnalysis.Tests/CodeAnalysisTest.cs @@ -0,0 +1,256 @@ +using Microsoft.CodeAnalysis; + +namespace Devlooped.Extensions.AI.CodeAnalysis.Tests; + +public sealed class AddChatClientsAnalyzerTests +{ + [Fact] + public async Task AddChatClients_reports_DEAI001() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + + class Program + { + static void Main() + { + var builder = new HostApplicationBuilder(); + builder.AddChatClients(); + } + } + """; + + var diagnostics = await RoslynTestHarness.GetAnalyzerDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == DiagnosticIds.AddChatClientsRemoved); + } + + [Fact] + public async Task Complex_configure_reports_DEAI002() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + + class Program + { + static void Main() + { + var builder = new HostApplicationBuilder(); + builder.AddChatClients(configure: (name, b) => + { + switch (name) + { + case "AI:Clients:Grok": break; + } + }); + } + } + """; + + var diagnostics = await RoslynTestHarness.GetAnalyzerDiagnosticsAsync(source); + Assert.Contains(diagnostics, d => d.Id == DiagnosticIds.AddChatClientsRemoved); + Assert.Contains(diagnostics, d => d.Id == DiagnosticIds.ConfigureCallbackNotMigratable); + } +} + +public sealed class AddChatClientsCodeFixTests +{ + static string Normalize(string value) => string.Join('\n', value.Replace("\r\n", "\n").Split('\n').Select(l => l.TrimEnd())); + + [Fact] + public async Task Builder_without_configure_migrates_to_AddAIClients() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + + class Program + { + static void Main() + { + var builder = new HostApplicationBuilder(); + builder.AddChatClients(); + } + } + """; + + const string expected = """ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + + class Program + { + static void Main() + { + var builder = new HostApplicationBuilder(); + builder.AddAIClients(); + } + } + """; + + var fixedSource = await RoslynTestHarness.ApplyCodeFixAsync(source); + Assert.Equal(Normalize(expected), Normalize(fixedSource)); + } + + [Fact] + public async Task Services_with_positional_configure_migrates() + { + const string source = """ + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.AI; + + class Program + { + static void Main(IConfiguration configuration, IServiceCollection services) + { + services.AddChatClients(configuration, (name, builder) => { builder.GetType(); }, useDefaultProviders: false); + } + } + """; + + var fixedSource = await RoslynTestHarness.ApplyCodeFixAsync(source); + Assert.Contains("ConfigureChatClientDefaults", fixedSource); + Assert.Contains("AddAIClients(configuration", fixedSource); + Assert.Contains("useDefaultProviders: false", fixedSource); + Assert.DoesNotContain("AddChatClients", fixedSource); + } + + [Fact] + public async Task Services_with_prefix_migrates_and_preserves_args() + { + const string source = """ + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + + class Program + { + static void Main(IConfiguration configuration, IServiceCollection services) + { + services.AddChatClients(configuration, prefix: "ai:clients"); + } + } + """; + + const string expected = """ + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + + class Program + { + static void Main(IConfiguration configuration, IServiceCollection services) + { + services.AddAIClients(configuration, prefix: "ai:clients"); + } + } + """; + + var fixedSource = await RoslynTestHarness.ApplyCodeFixAsync(source); + Assert.Equal(Normalize(expected), Normalize(fixedSource)); + } + + [Fact] + public async Task Configure_expression_body_migrates_to_global_defaults() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.AI; + + class Program + { + static void Main() + { + var builder = new HostApplicationBuilder(); + builder.AddChatClients(configure: (name, b) => { b.GetType(); }); + } + } + """; + + var fixedSource = await RoslynTestHarness.ApplyCodeFixAsync(source); + Assert.Contains("ConfigureChatClientDefaults(b=>b.GetType())", Normalize(fixedSource).Replace(" ", "")); + Assert.Contains("AddAIClients", fixedSource); + Assert.DoesNotContain("AddChatClients", fixedSource); + } + + [Fact] + public async Task Positional_configure_expression_renames_builder_parameter() + { + const string source = """ + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.AI; + + class Program + { + static void Main(IConfiguration configuration, IServiceCollection collection) + { + collection.AddChatClients(configuration, (name, builder) => builder.UseLogging(), useDefaultProviders: false); + } + } + """; + + var fixedSource = await RoslynTestHarness.ApplyCodeFixAsync(source); + var normalized = Normalize(fixedSource).Replace(" ", ""); + Assert.Contains("ConfigureChatClientDefaults(b=>b.UseLogging())", normalized); + Assert.Contains("AddAIClients(configuration,useDefaultProviders:false)", normalized); + Assert.DoesNotContain("builder.UseLogging", normalized); + Assert.DoesNotContain("AddChatClients", fixedSource); + } + + [Fact] + public async Task Configure_with_section_branch_migrates() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.AI; + + class Program + { + static void Main() + { + var builder = new HostApplicationBuilder(); + builder.AddChatClients(configure: (name, b) => + { + b.GetType(); + if (name == "AI:Clients:Grok") + b.GetHashCode(); + }); + } + } + """; + + var fixedSource = await RoslynTestHarness.ApplyCodeFixAsync(source); + Assert.Contains("ConfigureChatClientDefaults", fixedSource); + Assert.Contains("ConfigureChatClientDefaults(\"AI:Clients:Grok\"", fixedSource); + Assert.Contains("AddAIClients", fixedSource); + } + + [Fact] + public async Task Complex_configure_offers_no_code_fix() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + + class Program + { + static void Main() + { + var builder = new HostApplicationBuilder(); + builder.AddChatClients(configure: (name, b) => + { + switch (name) + { + case "AI:Clients:Grok": break; + } + }); + } + } + """; + + await Assert.ThrowsAsync(() => RoslynTestHarness.ApplyCodeFixAsync(source)); + } +} diff --git a/src/Extensions.CodeAnalysis.Tests/Extensions.CodeAnalysis.Tests.csproj b/src/Extensions.CodeAnalysis.Tests/Extensions.CodeAnalysis.Tests.csproj new file mode 100644 index 0000000..abbeecd --- /dev/null +++ b/src/Extensions.CodeAnalysis.Tests/Extensions.CodeAnalysis.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + Preview + false + true + Devlooped.Extensions.AI.CodeAnalysis.Tests + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Extensions.CodeAnalysis.Tests/RoslynTestHarness.cs b/src/Extensions.CodeAnalysis.Tests/RoslynTestHarness.cs new file mode 100644 index 0000000..4935cfa --- /dev/null +++ b/src/Extensions.CodeAnalysis.Tests/RoslynTestHarness.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using Basic.Reference.Assemblies; +using Devlooped.Extensions.AI.CodeFix; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Devlooped.Extensions.AI.CodeAnalysis.Tests; + +static class RoslynTestHarness +{ + internal static readonly MetadataReference[] References = + [ + ..Net80.References.All, + MetadataReference.CreateFromFile(typeof(ConfigurableChatClientExtensions).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.AI.ChatClientBuilder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location), + MetadataReference.CreateFromFile(typeof(HostApplicationBuilder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IHostApplicationBuilder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IConfiguration).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ILoggerFactory).Assembly.Location), + ]; + + public static async Task> GetAnalyzerDiagnosticsAsync(string source, CancellationToken cancellationToken = default) + { + var (compilation, _) = await CreateDocumentAndCompilationAsync(source, cancellationToken).ConfigureAwait(false); + var analyzer = new AddChatClientsAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).ConfigureAwait(false); + } + + public static async Task ApplyCodeFixAsync(string source, CancellationToken cancellationToken = default) + { + var (compilation, document) = await CreateDocumentAndCompilationAsync(source, cancellationToken).ConfigureAwait(false); + var analyzer = new AddChatClientsAnalyzer(); + var diagnostics = await compilation + .WithAnalyzers(ImmutableArray.Create(analyzer)) + .GetAnalyzerDiagnosticsAsync(cancellationToken) + .ConfigureAwait(false); + + var deai001 = diagnostics.First(d => d.Id == DiagnosticIds.AddChatClientsRemoved); + + var provider = new AddChatClientsCodeFixProvider(); + var actions = new List(); + var context = new CodeFixContext( + document, + deai001.Location.SourceSpan, + ImmutableArray.Create(deai001), + (action, _) => actions.Add(action), + cancellationToken); + + await provider.RegisterCodeFixesAsync(context).ConfigureAwait(false); + if (actions.Count == 0) + throw new InvalidOperationException("No code fix was registered."); + + var operations = await actions[0].GetOperationsAsync(cancellationToken).ConfigureAwait(false); + var solution = document.Project.Solution; + foreach (var operation in operations) + { + if (operation is ApplyChangesOperation apply) + solution = apply.ChangedSolution; + } + + var fixedDocument = solution.GetDocument(document.Id)!; + return (await fixedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString(); + } + + static async Task<(Compilation Compilation, Document Document)> CreateDocumentAndCompilationAsync( + string source, + CancellationToken cancellationToken) + { + var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var documentId = DocumentId.CreateNewId(projectId); + + var solution = workspace.CurrentSolution + .AddProject(projectId, "Test", "Test", LanguageNames.CSharp) + .AddMetadataReferences(projectId, References) + .AddDocument(documentId, "Test.cs", source); + + workspace.TryApplyChanges(solution); + var document = workspace.CurrentSolution.GetDocument(documentId)!; + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Failed to create compilation."); + return (compilation, document); + } +} diff --git a/src/Extensions.CodeAnalysis/AddChatClientsAnalyzer.cs b/src/Extensions.CodeAnalysis/AddChatClientsAnalyzer.cs new file mode 100644 index 0000000..cab42ba --- /dev/null +++ b/src/Extensions.CodeAnalysis/AddChatClientsAnalyzer.cs @@ -0,0 +1,66 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Devlooped.Extensions.AI.CodeAnalysis; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AddChatClientsAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = + [DiagnosticDescriptors.AddChatClientsRemoved, DiagnosticDescriptors.ConfigureCallbackNotMigratable]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation); + } + + static void AnalyzeInvocation(OperationAnalysisContext context) + { + if (context.Operation is not IInvocationOperation { TargetMethod: { } method } invocation) + return; + + if (!IsAddChatClients(method)) + return; + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.AddChatClientsRemoved, + invocation.Syntax.GetLocation())); + + var configureArgument = AddChatClientsInvocationHelper.FindConfigureArgument(invocation); + if (configureArgument is null) + return; + + var isServiceCollection = IsServiceCollectionInvocation(invocation, method); + + if (ConfigureCallbackMigrator.CanMigrate(configureArgument, isServiceCollection, context.CancellationToken)) + return; + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ConfigureCallbackNotMigratable, + configureArgument.GetLocation())); + } + + static bool IsAddChatClients(IMethodSymbol method) + { + var unreduced = method.ReducedFrom ?? method; + return unreduced.Name == "AddChatClients" + && unreduced.ContainingType?.Name == "ConfigurableChatClientExtensions" + && unreduced.ContainingType.ContainingNamespace?.ToDisplayString() == "Microsoft.Extensions.DependencyInjection"; + } + + static bool IsServiceCollectionInvocation(IInvocationOperation invocation, IMethodSymbol method) + { + var unreduced = method.ReducedFrom ?? method; + if (unreduced.Parameters.FirstOrDefault()?.Type.Name == "IServiceCollection") + return true; + + if (invocation.Instance?.Type?.Name is "IServiceCollection") + return true; + + return invocation.Arguments.Any(a => a.Parameter?.Name == "services"); + } +} diff --git a/src/Extensions.CodeAnalysis/AddChatClientsInvocationHelper.cs b/src/Extensions.CodeAnalysis/AddChatClientsInvocationHelper.cs new file mode 100644 index 0000000..d74b111 --- /dev/null +++ b/src/Extensions.CodeAnalysis/AddChatClientsInvocationHelper.cs @@ -0,0 +1,56 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace Devlooped.Extensions.AI.CodeAnalysis; + +/// +/// Resolves AddChatClients invocation arguments using semantic argument-to-parameter binding. +/// +public static class AddChatClientsInvocationHelper +{ + public static ArgumentSyntax? FindConfigureArgument(IInvocationOperation invocation) + { + foreach (var argument in invocation.Arguments) + { + if (argument.Parameter?.Name == "configure" && argument.Syntax is ArgumentSyntax syntax) + return syntax; + } + + return null; + } + + public static ArgumentSyntax? FindConfigureArgument( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken = default) + { + if (semanticModel.GetOperation(invocation, cancellationToken) is not IInvocationOperation invocationOperation) + return null; + + return FindConfigureArgument(invocationOperation); + } + + public static IEnumerable GetNonConfigureArguments( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken = default) + { + if (semanticModel.GetOperation(invocation, cancellationToken) is not IInvocationOperation invocationOperation) + { + foreach (var argument in invocation.ArgumentList.Arguments) + { + if (argument.NameColon?.Name.Identifier.Text != "configure") + yield return argument; + } + + yield break; + } + + foreach (var argument in invocationOperation.Arguments) + { + if (argument.Parameter?.Name != "configure" && argument.Syntax is ArgumentSyntax syntax) + yield return syntax; + } + } +} diff --git a/src/Extensions.CodeAnalysis/AnalyzerReleases.Shipped.md b/src/Extensions.CodeAnalysis/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..39071b5 --- /dev/null +++ b/src/Extensions.CodeAnalysis/AnalyzerReleases.Shipped.md @@ -0,0 +1,6 @@ +## Release 1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- diff --git a/src/Extensions.CodeAnalysis/AnalyzerReleases.Unshipped.md b/src/Extensions.CodeAnalysis/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..1602454 --- /dev/null +++ b/src/Extensions.CodeAnalysis/AnalyzerReleases.Unshipped.md @@ -0,0 +1,6 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DEAI001 | Devlooped.Extensions.AI | Error | AddChatClients was removed +DEAI002 | Devlooped.Extensions.AI | Warning | Configure callback cannot be migrated automatically diff --git a/src/Extensions.CodeAnalysis/ConfigureCallbackMigrator.cs b/src/Extensions.CodeAnalysis/ConfigureCallbackMigrator.cs new file mode 100644 index 0000000..c0ff83c --- /dev/null +++ b/src/Extensions.CodeAnalysis/ConfigureCallbackMigrator.cs @@ -0,0 +1,212 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Devlooped.Extensions.AI.CodeAnalysis; + +/// +/// Analyzes configure: (name, b) => ... callbacks for tiered migration to ConfigureChatClientDefaults. +/// +public static class ConfigureCallbackMigrator +{ + public static bool CanMigrate(ArgumentSyntax configureArgument, bool isServiceCollection, CancellationToken cancellationToken) + => TryAnalyze(configureArgument, isServiceCollection, cancellationToken) is not null; + + public static MigrationPlan? TryAnalyze(ArgumentSyntax configureArgument, bool isServiceCollection, CancellationToken cancellationToken) + { + if (configureArgument.Expression is not LambdaExpressionSyntax lambda) + return null; + + if (lambda is not ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters.Count: 2 } parenthesized) + return null; + + var nameParam = parenthesized.ParameterList.Parameters[0].Identifier.Text; + var builderParam = parenthesized.ParameterList.Parameters[1].Identifier.Text; + + if (lambda.Body is ExpressionSyntax expressionBody) + { + if (!UsesBuilder(expressionBody, builderParam)) + return null; + + return new MigrationPlan( + [new SectionDefaults(null, expressionBody, builderParam)], + isServiceCollection); + } + + if (lambda.Body is not BlockSyntax block) + return null; + + var globalStatements = new List(); + var sectionDefaults = new List(); + var hasUnsupported = false; + + foreach (var statement in block.Statements) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (IsUnconditionalBuilderStatement(statement, builderParam)) + { + globalStatements.Add(statement); + continue; + } + + if (TryParseSectionBranch(statement, nameParam, builderParam, out var sectionPath, out var branchBody)) + { + sectionDefaults.Add(new SectionDefaults(sectionPath, branchBody, builderParam)); + continue; + } + + hasUnsupported = true; + break; + } + + if (hasUnsupported) + return null; + + if (globalStatements.Count == 0 && sectionDefaults.Count == 0) + return null; + + var sections = new List(); + if (globalStatements.Count > 0) + sections.Add(new SectionDefaults(null, StatementsToBody(globalStatements), builderParam)); + + sections.AddRange(sectionDefaults); + return new MigrationPlan(sections, isServiceCollection); + } + + static bool IsUnconditionalBuilderStatement(StatementSyntax statement, string builderParam) + => statement switch + { + ExpressionStatementSyntax { Expression: var expr } => UsesBuilder(expr, builderParam), + _ => false, + }; + + static bool TryParseSectionBranch( + StatementSyntax statement, + string nameParam, + string builderParam, + out string sectionPath, + out CSharpSyntaxNode branchBody) + { + sectionPath = null!; + branchBody = null!; + + if (statement is not IfStatementSyntax { Else: null } ifStatement) + return false; + + if (!TryGetSectionPath(ifStatement.Condition, nameParam, out sectionPath)) + return false; + + if (ifStatement.Statement is BlockSyntax branchBlock) + { + if (branchBlock.Statements.Count != 1) + return false; + + if (!IsUnconditionalBuilderStatement(branchBlock.Statements[0], builderParam)) + return false; + + branchBody = branchBlock.Statements[0] is ExpressionStatementSyntax expr + ? expr.Expression + : branchBlock.Statements[0]; + return true; + } + + if (IsUnconditionalBuilderStatement(ifStatement.Statement, builderParam)) + { + branchBody = ifStatement.Statement is ExpressionStatementSyntax expr + ? expr.Expression + : ifStatement.Statement; + return true; + } + + return false; + } + + static bool TryGetSectionPath(ExpressionSyntax condition, string nameParam, out string sectionPath) + { + sectionPath = null!; + + switch (condition) + { + case BinaryExpressionSyntax { RawKind: (int)SyntaxKind.EqualsExpression } binary + when binary.Left is IdentifierNameSyntax left && left.Identifier.Text == nameParam + && binary.Right is LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } literal: + sectionPath = literal.Token.ValueText; + return true; + + case InvocationExpressionSyntax invocation + when invocation.Expression is MemberAccessExpressionSyntax + { + Name.Identifier.Text: "Equals", + Expression: IdentifierNameSyntax target, + } + && target.Identifier.Text == nameParam: + return TryGetEqualsSectionPath(invocation, out sectionPath); + + case InvocationExpressionSyntax staticEquals + when staticEquals.Expression is MemberAccessExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.Text: "string" }, + Name.Identifier.Text: "Equals", + } + && staticEquals.ArgumentList.Arguments.Count >= 2 + && staticEquals.ArgumentList.Arguments[0].Expression is IdentifierNameSyntax first + && first.Identifier.Text == nameParam: + return TryGetEqualsSectionPath(staticEquals, out sectionPath); + + default: + return false; + } + } + + static bool TryGetEqualsSectionPath(InvocationExpressionSyntax invocation, out string sectionPath) + { + sectionPath = null!; + + if (invocation.ArgumentList.Arguments.Count is 1 or 2) + { + var first = invocation.ArgumentList.Arguments[0].Expression; + if (first is LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } literal) + { + sectionPath = literal.Token.ValueText; + return true; + } + } + + if (invocation.ArgumentList.Arguments.Count == 3 + && invocation.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax path + && path.RawKind == (int)SyntaxKind.StringLiteralExpression) + { + sectionPath = path.Token.ValueText; + return true; + } + + return false; + } + + static bool UsesBuilder(ExpressionSyntax expression, string builderParam) + { + foreach (var identifier in expression.DescendantNodesAndSelf().OfType()) + { + if (identifier.Identifier.Text == builderParam) + return true; + } + + return false; + } + + static CSharpSyntaxNode StatementsToBody(IReadOnlyList statements) + { + if (statements.Count == 1 && statements[0] is ExpressionStatementSyntax expr) + return expr.Expression; + + return SyntaxFactory.Block(statements); + } + + public static LambdaExpressionSyntax CreateConfigureDefaultsLambda(SectionDefaults section) + => ConfigureCallbackSyntax.CreateDefaultsLambda(section); + + public sealed record MigrationPlan(IReadOnlyList Sections, bool IsServiceCollection); + + public sealed record SectionDefaults(string? SectionPath, CSharpSyntaxNode Body, string BuilderParameterName); +} diff --git a/src/Extensions.CodeAnalysis/ConfigureCallbackSyntax.cs b/src/Extensions.CodeAnalysis/ConfigureCallbackSyntax.cs new file mode 100644 index 0000000..dd5ebcf --- /dev/null +++ b/src/Extensions.CodeAnalysis/ConfigureCallbackSyntax.cs @@ -0,0 +1,53 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Devlooped.Extensions.AI.CodeAnalysis; + +static class ConfigureCallbackSyntax +{ + internal const string DefaultsBuilderParameterName = "b"; + + public static LambdaExpressionSyntax CreateDefaultsLambda(ConfigureCallbackMigrator.SectionDefaults section) + { + var body = RenameBuilderIdentifier(section.Body, section.BuilderParameterName, DefaultsBuilderParameterName); + + if (body is ExpressionSyntax expression) + return SyntaxFactory.SimpleLambdaExpression(CreateBuilderParameter(), expression); + + if (body is BlockSyntax { Statements.Count: 1 } block + && block.Statements[0] is ExpressionStatementSyntax { Expression: var single }) + { + var expr = RenameBuilderIdentifier(single, section.BuilderParameterName, DefaultsBuilderParameterName); + return SyntaxFactory.SimpleLambdaExpression(CreateBuilderParameter(), expr); + } + + var blockBody = body as BlockSyntax ?? SyntaxFactory.Block((StatementSyntax)body); + return SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(SyntaxFactory.SingletonSeparatedList(CreateBuilderParameter())), + blockBody); + } + + static ParameterSyntax CreateBuilderParameter() + => SyntaxFactory.Parameter(SyntaxFactory.Identifier(DefaultsBuilderParameterName)); + + static CSharpSyntaxNode RenameBuilderIdentifier(CSharpSyntaxNode node, string oldName, string newName) + { + if (oldName == newName) + return node; + + return (CSharpSyntaxNode)new BuilderParameterRenameRewriter(oldName, newName).Visit(node)! ?? node; + } + + sealed class BuilderParameterRenameRewriter(string oldName, string newName) : CSharpSyntaxRewriter + { + public override SyntaxToken VisitToken(SyntaxToken token) + { + if (token.IsKind(SyntaxKind.IdentifierToken) && token.Text == oldName) + return SyntaxFactory.Identifier(newName).WithTriviaFrom(token); + + return base.VisitToken(token); + } + } + +} diff --git a/src/Extensions.CodeAnalysis/DiagnosticDescriptors.cs b/src/Extensions.CodeAnalysis/DiagnosticDescriptors.cs new file mode 100644 index 0000000..6fa7c69 --- /dev/null +++ b/src/Extensions.CodeAnalysis/DiagnosticDescriptors.cs @@ -0,0 +1,24 @@ +using Microsoft.CodeAnalysis; + +namespace Devlooped.Extensions.AI.CodeAnalysis; + +static class DiagnosticDescriptors +{ + public static DiagnosticDescriptor AddChatClientsRemoved { get; } = new( + DiagnosticIds.AddChatClientsRemoved, + title: "AddChatClients was removed", + messageFormat: "AddChatClients was removed; use AddAIClients and ConfigureChatClientDefaults instead", + category: "Devlooped.Extensions.AI", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "AddChatClients was removed in favor of AddAIClients and ConfigureChatClientDefaults."); + + public static DiagnosticDescriptor ConfigureCallbackNotMigratable { get; } = new( + DiagnosticIds.ConfigureCallbackNotMigratable, + title: "Configure callback cannot be migrated automatically", + messageFormat: "The configure callback cannot be migrated automatically; convert it manually to ConfigureChatClientDefaults call(s) before AddAIClients", + category: "Devlooped.Extensions.AI", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The configure callback uses patterns that cannot be converted safely to ConfigureChatClientDefaults."); +} diff --git a/src/Extensions.CodeAnalysis/DiagnosticIds.cs b/src/Extensions.CodeAnalysis/DiagnosticIds.cs new file mode 100644 index 0000000..63517b3 --- /dev/null +++ b/src/Extensions.CodeAnalysis/DiagnosticIds.cs @@ -0,0 +1,7 @@ +namespace Devlooped.Extensions.AI.CodeAnalysis; + +public static class DiagnosticIds +{ + public const string AddChatClientsRemoved = "DEAI001"; + public const string ConfigureCallbackNotMigratable = "DEAI002"; +} diff --git a/src/Extensions.CodeAnalysis/Extensions.CodeAnalysis.csproj b/src/Extensions.CodeAnalysis/Extensions.CodeAnalysis.csproj index ae5f3b2..d11ef15 100644 --- a/src/Extensions.CodeAnalysis/Extensions.CodeAnalysis.csproj +++ b/src/Extensions.CodeAnalysis/Extensions.CodeAnalysis.csproj @@ -5,13 +5,18 @@ true analyzers/dotnet/roslyn5.0/cs true + $(NoWarn);RS2002 + - + + + + diff --git a/src/Extensions.CodeAnalysis/Properties/launchSettings.json b/src/Extensions.CodeAnalysis/Properties/launchSettings.json new file mode 100644 index 0000000..2589d8e --- /dev/null +++ b/src/Extensions.CodeAnalysis/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Extensions.CodeAnalysis": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\Tests\\Tests.csproj" + } + } +} \ No newline at end of file diff --git a/src/Extensions.Codefix/AddChatClientsCodeFixProvider.cs b/src/Extensions.Codefix/AddChatClientsCodeFixProvider.cs new file mode 100644 index 0000000..71ec7b3 --- /dev/null +++ b/src/Extensions.Codefix/AddChatClientsCodeFixProvider.cs @@ -0,0 +1,180 @@ +using System.Collections.Immutable; +using System.Composition; +using Devlooped.Extensions.AI.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; + +namespace Devlooped.Extensions.AI.CodeFix; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddChatClientsCodeFixProvider)), Shared] +public sealed class AddChatClientsCodeFixProvider : CodeFixProvider +{ + public const string FixTitle = "Migrate to AddAIClients"; + + public override ImmutableArray FixableDiagnosticIds { get; } = + [DiagnosticIds.AddChatClientsRemoved]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + if (diagnostic.Location.SourceTree is null) + continue; + + var node = root.FindNode(diagnostic.Location.SourceSpan); + var invocation = node.FirstAncestorOrSelf(); + if (invocation is null) + continue; + + var configureArgument = AddChatClientsInvocationHelper.FindConfigureArgument(invocation, semanticModel, context.CancellationToken); + var isServiceCollection = IsServiceCollectionInvocation(invocation, semanticModel, context.CancellationToken); + + if (configureArgument is not null + && ConfigureCallbackMigrator.TryAnalyze(configureArgument, isServiceCollection, context.CancellationToken) is null) + { + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + FixTitle, + cancellationToken => ApplyFixAsync(context.Document, invocation, semanticModel, cancellationToken), + equivalenceKey: FixTitle), + diagnostic); + } + } + + static async Task ApplyFixAsync( + Document document, + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + var configureArgument = AddChatClientsInvocationHelper.FindConfigureArgument(invocation, semanticModel, cancellationToken); + + ExpressionSyntax receiver; + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + receiver = memberAccess.Expression; + else + return document; + + var isServiceCollection = IsServiceCollectionInvocation(invocation, semanticModel, cancellationToken); + var addAiClientsArguments = GetAddAIClientsArguments(invocation, semanticModel, cancellationToken); + + if (configureArgument is not null + && ConfigureCallbackMigrator.TryAnalyze(configureArgument, isServiceCollection, cancellationToken) is { } plan) + { + ExpressionSyntax current = receiver; + foreach (var section in plan.Sections) + current = CreateConfigureDefaultsCall(current, section); + + var addAiClients = CreateAddAIClientsInvocation(current, addAiClientsArguments); + editor.ReplaceNode(invocation, addAiClients); + return editor.GetChangedDocument(); + } + + var simpleAdd = CreateAddAIClientsInvocation(receiver, addAiClientsArguments); + if (invocation.Parent is ExpressionSyntax parent && parent is MemberAccessExpressionSyntax or InvocationExpressionSyntax) + { + editor.ReplaceNode(invocation, simpleAdd); + } + else if (invocation.Parent is ExpressionStatementSyntax statement) + { + editor.ReplaceNode(statement, SyntaxFactory.ExpressionStatement(simpleAdd).WithTrailingTrivia(statement.GetTrailingTrivia())); + } + else + { + editor.ReplaceNode(invocation, simpleAdd); + } + + return editor.GetChangedDocument(); + } + + static ExpressionSyntax CreateConfigureDefaultsCall( + ExpressionSyntax receiver, + ConfigureCallbackMigrator.SectionDefaults section) + { + var configureLambda = ConfigureCallbackMigrator.CreateConfigureDefaultsLambda(section); + + if (section.SectionPath is { } path) + { + var args = SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList([ + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(path))), + SyntaxFactory.Argument(configureLambda), + ])); + + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + SyntaxFactory.IdentifierName("ConfigureChatClientDefaults")), + args); + } + + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + SyntaxFactory.IdentifierName("ConfigureChatClientDefaults")), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(configureLambda)))); + } + + static SeparatedSyntaxList GetAddAIClientsArguments( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken) + => SyntaxFactory.SeparatedList( + AddChatClientsInvocationHelper.GetNonConfigureArguments(invocation, semanticModel, cancellationToken).ToArray()); + + static InvocationExpressionSyntax CreateAddAIClientsInvocation( + ExpressionSyntax receiver, + SeparatedSyntaxList arguments) + { + var argumentList = arguments.Count == 0 + ? SyntaxFactory.ArgumentList() + : SyntaxFactory.ArgumentList(arguments); + + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + SyntaxFactory.IdentifierName("AddAIClients")), + argumentList); + } + + static bool IsServiceCollectionInvocation(InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return false; + + var receiverType = semanticModel.GetTypeInfo(memberAccess.Expression, cancellationToken).Type; + return receiverType?.Name == "IServiceCollection"; + } + + static ArgumentSyntax? GetNamedArgument(InvocationExpressionSyntax invocation, string name) + { + foreach (var argument in invocation.ArgumentList.Arguments) + { + if (argument.NameColon?.Name.Identifier.Text == name) + return argument; + } + + return null; + } + +} diff --git a/src/Extensions.Codefix/Extensions.Codefix.csproj b/src/Extensions.Codefix/Extensions.Codefix.csproj new file mode 100644 index 0000000..51fd1b1 --- /dev/null +++ b/src/Extensions.Codefix/Extensions.Codefix.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + true + analyzers/dotnet/roslyn5.0/cs + true + + + + + + + + + + + + + + diff --git a/src/Extensions/Extensions.csproj b/src/Extensions/Extensions.csproj index 2fabc6b..47f9b18 100644 --- a/src/Extensions/Extensions.csproj +++ b/src/Extensions/Extensions.csproj @@ -33,7 +33,8 @@ - + + diff --git a/src/Extensions/ObsoleteExtensions.cs b/src/Extensions/ObsoleteExtensions.cs index a3193ed..8ebd459 100644 --- a/src/Extensions/ObsoleteExtensions.cs +++ b/src/Extensions/ObsoleteExtensions.cs @@ -13,14 +13,14 @@ public static class ConfigurableChatClientExtensions { /// Obsolete. Use the new AddAIClients method. [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Use the new AddAIClients method.", true, DiagnosticId = "DEAI001")] + [Obsolete("Use AddAIClients and ConfigureChatClientDefaults instead. See diagnostic DEAI001.")] public static TBuilder AddChatClients(this TBuilder builder, Action? configure = default, string prefix = "ai:clients", bool useDefaultProviders = true) where TBuilder : IHostApplicationBuilder => throw new NotSupportedException(); /// Obsolete. Use the new AddAIClients method. [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Use the new AddAIClients method.", true, DiagnosticId = "DEAI001")] + [Obsolete("Use AddAIClients and ConfigureChatClientDefaults instead. See diagnostic DEAI001.")] public static IServiceCollection AddChatClients(this IServiceCollection services, IConfiguration configuration, Action? configure = default, string prefix = "ai:clients", bool useDefaultProviders = true) => throw new NotSupportedException(); } \ No newline at end of file diff --git a/src/Extensions/SKILL.md b/src/Extensions/SKILL.md index 4039b45..a97181d 100644 --- a/src/Extensions/SKILL.md +++ b/src/Extensions/SKILL.md @@ -11,7 +11,11 @@ source) using provider detection, and support hot-reload without restarting the ## DEAI001 — Migrate from AddChatClients to AddAIClients -`AddChatClients` is **removed** (error, `DiagnosticId = "DEAI001"`). Replace every call site: +`AddChatClients` is **removed** (analyzer error **DEAI001**). In Visual Studio or Rider, use the +**Migrate to AddAIClients** code fix (light bulb) on the error for deterministic migration. When the +`configure` callback is too complex for the fixer (**DEAI002**), follow the manual steps below. + +Replace every call site: ### Before (error) diff --git a/src/Tests/ConfigurableClientTests.cs b/src/Tests/ConfigurableClientTests.cs index 3f5a9ac..80a5d00 100644 --- a/src/Tests/ConfigurableClientTests.cs +++ b/src/Tests/ConfigurableClientTests.cs @@ -8,6 +8,19 @@ namespace Devlooped.Extensions.AI; public class ConfigurableClientTests(ITestOutputHelper output) { + [Fact] + public void Migrate() + { + var configuration = new ConfigurationBuilder().Build(); + var collection = new ServiceCollection(); + + collection.ConfigureChatClientDefaults(b => b.UseLogging()) + .AddAIClients(configuration, useDefaultProviders: false); + + var services = collection.BuildServiceProvider(); + } + + [Fact] public void CanConfigureClients() { diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index ab4eaed..534c489 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -2,7 +2,7 @@ net10.0 - OPENAI001;MEAI001;DEAI001;$(NoWarn) + OPENAI001;MEAI001;$(NoWarn) Preview true Devlooped @@ -33,6 +33,7 @@ +