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 @@
+