From b35cb06f854ab932f3a0f9b7253f5c04b99d4754 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:02:59 +0000 Subject: [PATCH 1/2] Initial plan From a6f4fcb37fde08b4b2b668450b1724b121a1ae01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:08:27 +0000 Subject: [PATCH 2/2] fix(adapter): Add tests and fix cross-host collision detection, member locations, and declaration order Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/adapter.md | 2 +- .../Adapter/AdapterGenerator.cs | 62 ++++++++++----- .../AdapterGeneratorTests.cs | 75 +++++++++++++++++++ 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/docs/generators/adapter.md b/docs/generators/adapter.md index 1ac242d..33b2efc 100644 --- a/docs/generators/adapter.md +++ b/docs/generators/adapter.md @@ -254,7 +254,7 @@ public static partial class Adapters ### Multiple Adapters with Shared Adaptee -When defining multiple `[GenerateAdapter]` attributes within the same host class that share the same adaptee type, mapping ambiguity can occur. The generator matches `[AdapterMap]` methods to adapters solely by adaptee type and then by `TargetMember` name. If two target types have overlapping member names (both use `nameof(...)` resulting in the same string), mappings become ambiguous and may trigger false `PKADP004` duplicate mapping diagnostics. +When defining multiple `[GenerateAdapter]` attributes within the same host class that share the same adaptee type, mapping ambiguity can occur. The generator matches `[AdapterMap]` methods to adapters solely by adaptee type and then by `TargetMember` name. If two target types have overlapping member names (both use `nameof(...)` resulting in the same string), mappings become inherently ambiguous, and the generator cannot reliably determine which adapter a mapping belongs to. In this case, `PKADP004` duplicate mapping diagnostics are expected given the current API design, rather than being false positives, unless mappings are split into separate hosts or the API is extended to provide additional disambiguation. **Workaround:** Define separate host classes for each adapter when they share the same adaptee type: diff --git a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs index 85d8f5f..4623089 100644 --- a/src/PatternKit.Generators/Adapter/AdapterGenerator.cs +++ b/src/PatternKit.Generators/Adapter/AdapterGenerator.cs @@ -193,22 +193,28 @@ public void Initialize(IncrementalGeneratorInitializationContext context) transform: static (ctx, _) => ctx ); - // Generate for each host - context.RegisterSourceOutput(adapterHosts, (spc, typeContext) => - { - if (typeContext.TargetSymbol is not INamedTypeSymbol hostSymbol) - return; - - var node = typeContext.TargetNode; + // Collect all hosts so we can detect conflicts across the entire compilation + var collectedAdapterHosts = adapterHosts.Collect(); + // Generate for all hosts, tracking generated adapter type names globally + context.RegisterSourceOutput(collectedAdapterHosts, (spc, collectedTypeContexts) => + { // Track generated adapter type names to detect conflicts (namespace -> type name -> location) var generatedAdapters = new Dictionary>(); - // Process each [GenerateAdapter] attribute on the host - foreach (var attr in typeContext.Attributes.Where(a => - a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Adapter.GenerateAdapterAttribute")) + foreach (var typeContext in collectedTypeContexts) { - GenerateAdapterForAttribute(spc, hostSymbol, attr, node, typeContext.SemanticModel, generatedAdapters); + if (typeContext.TargetSymbol is not INamedTypeSymbol hostSymbol) + continue; + + var node = typeContext.TargetNode; + + // Process each [GenerateAdapter] attribute on the host + foreach (var attr in typeContext.Attributes.Where(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Adapter.GenerateAdapterAttribute")) + { + GenerateAdapterForAttribute(spc, hostSymbol, attr, node, typeContext.SemanticModel, generatedAdapters); + } } }); } @@ -497,7 +503,7 @@ private static bool HasTypeNameConflict(Compilation compilation, string ns, stri return compilation.GetTypeByMetadataName(fullName) is not null; } - private List ValidateTargetMembers(INamedTypeSymbol targetType, Location location) + private List ValidateTargetMembers(INamedTypeSymbol targetType, Location fallbackLocation) { var diagnostics = new List(); var isAbstractClass = targetType.TypeKind == TypeKind.Class && targetType.IsAbstract; @@ -522,6 +528,9 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca foreach (var member in membersToCheck) { + // Use member location if available, otherwise fall back to host location + var location = member.Locations.FirstOrDefault() ?? fallbackLocation; + // Check for static members (not supported) if (member.IsStatic) { @@ -620,7 +629,7 @@ private List ValidateTargetMembers(INamedTypeSymbol targetType, Loca { diagnostics.Add(Diagnostic.Create( OverloadedMethodsNotSupportedDescriptor, - location, + fallbackLocation, targetType.Name, kvp.Key)); } @@ -755,12 +764,27 @@ private static List GetTargetMembers(INamedTypeSymbol targetType) } } - // Ensure stable, deterministic ordering by kind+name+signature - // This provides a predictable output order even if member traversal is non-deterministic - return members.OrderBy(m => m.Kind) - .ThenBy(m => m.Name) - .ThenBy(m => m.ToDisplayString(FullyQualifiedFormat)) - .ToList(); + // Order by declaration order when available, falling back to stable sort for metadata-only symbols + // This provides both readable (contract-ordered) output and deterministic ordering + return members.OrderBy(m => + { + // Try to get syntax declaration order (file path + line number) + var syntaxRef = m.DeclaringSyntaxReferences.FirstOrDefault(); + if (syntaxRef != null) + { + var location = syntaxRef.GetSyntax().GetLocation(); + var lineSpan = location.GetLineSpan(); + // Return a tuple of (file path, line number) for natural ordering + return (lineSpan.Path, lineSpan.StartLinePosition.Line, 0); + } + // For metadata-only symbols without source, use a fallback ordering + // Use the symbol's metadata token which is stable across compilations + return (string.Empty, int.MaxValue, m.MetadataToken); + }) + .ThenBy(m => m.Kind) + .ThenBy(m => m.Name) + .ThenBy(m => m.ToDisplayString(FullyQualifiedFormat)) + .ToList(); } private static string GetMemberSignature(IMethodSymbol method) diff --git a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs index 80ca415..08a50cf 100644 --- a/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/AdapterGeneratorTests.cs @@ -1202,4 +1202,79 @@ public static partial class Adapters { } var diags = result.Results.SelectMany(r => r.Diagnostics); Assert.Contains(diags, d => d.Id == "PKADP017"); } + + [Fact] + public void ErrorWhenTargetHasIndexer() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IIndexable + { + string this[int index] { get; } // Indexer + } + + public class LegacyService { } + + [GenerateAdapter(Target = typeof(IIndexable), Adaptee = typeof(LegacyService))] + public static partial class Adapters { } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTargetHasIndexer)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP018 diagnostic is reported + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP018"); + } + + [Fact] + public void ErrorWhenTwoHostsGenerateSameAdapterTypeName() + { + const string source = """ + using PatternKit.Generators.Adapter; + + namespace TestNamespace; + + public interface IClock + { + System.DateTimeOffset Now { get; } + } + + public interface ITimer + { + long Ticks { get; } + } + + public class LegacyClock { } + public class LegacyTimer { } + + // First host generates LegacyClockAdapter + [GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock), AdapterTypeName = "SharedAdapter")] + public static partial class ClockAdapters + { + [AdapterMap(TargetMember = nameof(IClock.Now))] + public static System.DateTimeOffset MapNow(LegacyClock adaptee) => default; + } + + // Second host attempts to generate the same adapter type name + [GenerateAdapter(Target = typeof(ITimer), Adaptee = typeof(LegacyTimer), AdapterTypeName = "SharedAdapter")] + public static partial class TimerAdapters + { + [AdapterMap(TargetMember = nameof(ITimer.Ticks))] + public static long MapTicks(LegacyTimer adaptee) => default; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorWhenTwoHostsGenerateSameAdapterTypeName)); + var gen = new AdapterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // PKADP006 diagnostic is reported (at least once, possibly twice) + var diags = result.Results.SelectMany(r => r.Diagnostics); + Assert.Contains(diags, d => d.Id == "PKADP006"); + } }