From a9d8baa8b22a5f0741c8c376365636bffa1a9cce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 05:51:53 +0000 Subject: [PATCH 01/14] Initial plan From 17da0ad867912bd0233feebd3cc39d19863c1687 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 05:58:50 +0000 Subject: [PATCH 02/14] Implement PrototypeGenerator.cs with full diagnostic support and cloning strategies Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../Prototype/PrototypeAttribute.cs | 127 +++ .../AnalyzerReleases.Unshipped.md | 6 + .../PrototypeGenerator.cs | 793 ++++++++++++++++++ 3 files changed, 926 insertions(+) create mode 100644 src/PatternKit.Generators.Abstractions/Prototype/PrototypeAttribute.cs create mode 100644 src/PatternKit.Generators/PrototypeGenerator.cs diff --git a/src/PatternKit.Generators.Abstractions/Prototype/PrototypeAttribute.cs b/src/PatternKit.Generators.Abstractions/Prototype/PrototypeAttribute.cs new file mode 100644 index 0000000..1e7658f --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Prototype/PrototypeAttribute.cs @@ -0,0 +1,127 @@ +namespace PatternKit.Generators.Prototype; + +/// +/// Marks a type (class/struct/record class/record struct) for Prototype pattern code generation. +/// Generates a Clone() method with configurable cloning strategies for safe, deterministic object duplication. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class PrototypeAttribute : Attribute +{ + /// + /// The cloning mode that determines default behavior for reference types. + /// Default is ShallowWithWarnings (safe-by-default with diagnostics for mutable references). + /// + public PrototypeMode Mode { get; set; } = PrototypeMode.ShallowWithWarnings; + + /// + /// The name of the generated clone method. + /// Default is "Clone". + /// + public string CloneMethodName { get; set; } = "Clone"; + + /// + /// When true, only members explicitly marked with [PrototypeInclude] will be cloned. + /// When false (default), all eligible members are cloned unless marked with [PrototypeIgnore]. + /// + public bool IncludeExplicit { get; set; } +} + +/// +/// Determines the default cloning behavior for reference types. +/// +public enum PrototypeMode +{ + /// + /// Shallow clone with warnings for mutable reference types. + /// This is the safe-by-default mode that alerts developers to potential aliasing issues. + /// + ShallowWithWarnings = 0, + + /// + /// Shallow clone without warnings. + /// Use when you explicitly want shallow cloning and understand the implications. + /// + Shallow = 1, + + /// + /// Deep clone when deterministically possible. + /// Attempts to deep clone collections and types with known clone mechanisms. + /// Falls back to shallow for types without clone support. + /// + DeepWhenPossible = 2 +} + +/// +/// Marks a member to be excluded from the clone operation. +/// Only applies when IncludeExplicit is false (default mode). +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = false)] +public sealed class PrototypeIgnoreAttribute : Attribute +{ +} + +/// +/// Marks a member to be explicitly included in the clone operation. +/// Only applies when IncludeExplicit is true. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = false)] +public sealed class PrototypeIncludeAttribute : Attribute +{ +} + +/// +/// Specifies the cloning strategy for a specific member. +/// Overrides the default strategy determined by the PrototypeMode. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = false)] +public sealed class PrototypeStrategyAttribute : Attribute +{ + public PrototypeCloneStrategy Strategy { get; } + + public PrototypeStrategyAttribute(PrototypeCloneStrategy strategy) + { + Strategy = strategy; + } +} + +/// +/// Defines how a member's value is cloned. +/// +public enum PrototypeCloneStrategy +{ + /// + /// Copy the reference as-is (shallow copy). + /// Safe for immutable types and value types. + /// WARNING: For mutable reference types, mutations will affect both the original and clone. + /// + ByReference = 0, + + /// + /// Perform a shallow copy of the member. + /// For collections, creates a new collection with the same element references. + /// + ShallowCopy = 1, + + /// + /// Clone the value using a known mechanism: + /// - ICloneable.Clone() + /// - Clone() method returning same type + /// - Copy constructor T(T other) + /// - For collections like List<T>, creates new collection: new List<T>(original) + /// Generator emits an error if no suitable clone mechanism is available. + /// + Clone = 2, + + /// + /// Perform a deep copy of the member value. + /// Only available when the generator can safely emit deep copy logic. + /// For complex types, requires Clone strategy on nested members. + /// + DeepCopy = 3, + + /// + /// Use a custom clone mechanism provided by the user. + /// Requires a partial method: private static partial TMember Clone{MemberName}(TMember value); + /// + Custom = 4 +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 78073c8..c8131a7 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -52,4 +52,10 @@ PKDEC003 | PatternKit.Generators.Decorator | Error | Name conflict for generated PKDEC004 | PatternKit.Generators.Decorator | Warning | Member is not accessible for decorator generation PKDEC005 | PatternKit.Generators.Decorator | Error | Generic contracts are not supported for decorator generation PKDEC006 | PatternKit.Generators.Decorator | Error | Nested types are not supported for decorator generation +PKPRO001 | PatternKit.Generators.Prototype | Error | Type marked with [Prototype] must be partial +PKPRO002 | PatternKit.Generators.Prototype | Error | Cannot construct clone target (no supported clone construction path) +PKPRO003 | PatternKit.Generators.Prototype | Warning | Unsafe reference capture (mutable reference types) +PKPRO004 | PatternKit.Generators.Prototype | Error | Requested Clone strategy but no clone mechanism found +PKPRO005 | PatternKit.Generators.Prototype | Error | Custom strategy requires partial clone hook, but none found +PKPRO006 | PatternKit.Generators.Prototype | Warning | Include/Ignore attribute misuse diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs new file mode 100644 index 0000000..208ab82 --- /dev/null +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -0,0 +1,793 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Text; +using System.Linq; +using System.Collections.Generic; + +namespace PatternKit.Generators; + +/// +/// Source generator for the Prototype pattern. +/// Generates Clone methods with configurable cloning strategies for safe object duplication. +/// +[Generator] +public sealed class PrototypeGenerator : IIncrementalGenerator +{ + // Diagnostic IDs + private const string DiagIdTypeNotPartial = "PKPRO001"; + private const string DiagIdNoConstructionPath = "PKPRO002"; + private const string DiagIdUnsafeReferenceCapture = "PKPRO003"; + private const string DiagIdCloneMechanismMissing = "PKPRO004"; + private const string DiagIdCustomStrategyMissing = "PKPRO005"; + private const string DiagIdAttributeMisuse = "PKPRO006"; + + private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( + id: DiagIdTypeNotPartial, + title: "Type marked with [Prototype] must be partial", + messageFormat: "Type '{0}' is marked with [Prototype] but is not declared as partial. Add the 'partial' keyword to the type declaration.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NoConstructionPathDescriptor = new( + id: DiagIdNoConstructionPath, + title: "Cannot construct clone target", + messageFormat: "Cannot construct clone for type '{0}'. No supported clone construction path found (no parameterless constructor, copy constructor, or record with-expression support).", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor UnsafeReferenceCaptureDescriptor = new( + id: DiagIdUnsafeReferenceCapture, + title: "Unsafe reference capture", + messageFormat: "Member '{0}' is a mutable reference type copied by reference. Mutations will affect both the original and clone. Consider using [PrototypeStrategy(Clone)] or [PrototypeStrategy(ShallowCopy)].", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor CloneMechanismMissingDescriptor = new( + id: DiagIdCloneMechanismMissing, + title: "Requested Clone strategy but no clone mechanism found", + messageFormat: "Member '{0}' has [PrototypeStrategy(Clone)] but no suitable clone mechanism is available. Implement ICloneable, provide a Clone() method, copy constructor, or use a collection type with copy constructor support.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor CustomStrategyMissingDescriptor = new( + id: DiagIdCustomStrategyMissing, + title: "Custom strategy requires partial clone hook, but none found", + messageFormat: "Member '{0}' has [PrototypeStrategy(Custom)] but no partial method 'private static partial {1} Clone{0}({1} value)' was found. Declare this method in your partial type.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AttributeMisuseDescriptor = new( + id: DiagIdAttributeMisuse, + title: "Include/Ignore attribute misuse", + messageFormat: "{0}", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all type declarations with [Prototype] attribute + var prototypeTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Prototype.PrototypeAttribute", + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + // Generate for each type + context.RegisterSourceOutput(prototypeTypes, (spc, typeContext) => + { + if (typeContext.TargetSymbol is not INamedTypeSymbol typeSymbol) + return; + + var attr = typeContext.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Prototype.PrototypeAttribute"); + if (attr is null) + return; + + GeneratePrototypeForType(spc, typeSymbol, attr, typeContext.TargetNode); + }); + } + + private void GeneratePrototypeForType( + SourceProductionContext context, + INamedTypeSymbol typeSymbol, + AttributeData attribute, + SyntaxNode node) + { + // Check if type is partial + if (!IsPartialType(node)) + { + context.ReportDiagnostic(Diagnostic.Create( + TypeNotPartialDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Parse attribute arguments + var config = ParsePrototypeConfig(attribute); + + // Analyze type and members + var typeInfo = AnalyzeType(typeSymbol, config, context); + if (typeInfo is null) + return; + + // Generate clone method + var cloneSource = GenerateCloneMethod(typeInfo, config, context); + if (!string.IsNullOrEmpty(cloneSource)) + { + var fileName = $"{typeSymbol.Name}.Prototype.g.cs"; + context.AddSource(fileName, cloneSource); + } + } + + private static bool IsPartialType(SyntaxNode node) + { + return node switch + { + ClassDeclarationSyntax classDecl => classDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + StructDeclarationSyntax structDecl => structDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + RecordDeclarationSyntax recordDecl => recordDecl.Modifiers.Any(SyntaxKind.PartialKeyword), + _ => false + }; + } + + private PrototypeConfig ParsePrototypeConfig(AttributeData attribute) + { + var config = new PrototypeConfig(); + + foreach (var named in attribute.NamedArguments) + { + switch (named.Key) + { + case "Mode": + config.Mode = (int)named.Value.Value!; + break; + case "CloneMethodName": + config.CloneMethodName = (string)named.Value.Value!; + break; + case "IncludeExplicit": + config.IncludeExplicit = (bool)named.Value.Value!; + break; + } + } + + return config; + } + + private TypeInfo? AnalyzeType( + INamedTypeSymbol typeSymbol, + PrototypeConfig config, + SourceProductionContext context) + { + var typeInfo = new TypeInfo + { + TypeSymbol = typeSymbol, + TypeName = typeSymbol.Name, + Namespace = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : typeSymbol.ContainingNamespace.ToDisplayString(), + IsClass = typeSymbol.TypeKind == TypeKind.Class && !typeSymbol.IsRecord, + IsStruct = typeSymbol.TypeKind == TypeKind.Struct && !typeSymbol.IsRecord, + IsRecordClass = typeSymbol.TypeKind == TypeKind.Class && typeSymbol.IsRecord, + IsRecordStruct = typeSymbol.TypeKind == TypeKind.Struct && typeSymbol.IsRecord, + Members = new List() + }; + + // Collect members based on inclusion mode + var members = GetMembersForClone(typeSymbol, config, context); + typeInfo.Members.AddRange(members); + + // Determine construction strategy + typeInfo.ConstructionStrategy = DetermineConstructionStrategy(typeSymbol, typeInfo); + if (typeInfo.ConstructionStrategy == ConstructionStrategy.None) + { + context.ReportDiagnostic(Diagnostic.Create( + NoConstructionPathDescriptor, + typeSymbol.Locations.FirstOrDefault(), + typeSymbol.Name)); + return null; + } + + return typeInfo; + } + + private ConstructionStrategy DetermineConstructionStrategy(INamedTypeSymbol typeSymbol, TypeInfo typeInfo) + { + // For records, prefer with-expression if all members are init/readonly + if (typeInfo.IsRecordClass || typeInfo.IsRecordStruct) + { + bool allInit = typeInfo.Members.All(m => m.IsInitOnly || m.IsReadOnly); + if (allInit) + return ConstructionStrategy.RecordWith; + + // Otherwise try copy constructor or parameterless + if (HasCopyConstructor(typeSymbol)) + return ConstructionStrategy.CopyConstructor; + + if (HasParameterlessConstructor(typeSymbol)) + return ConstructionStrategy.ParameterlessConstructor; + + return ConstructionStrategy.RecordWith; // Fall back to with-expression + } + + // For classes/structs, try copy constructor first, then parameterless + if (HasCopyConstructor(typeSymbol)) + return ConstructionStrategy.CopyConstructor; + + if (HasParameterlessConstructor(typeSymbol)) + return ConstructionStrategy.ParameterlessConstructor; + + return ConstructionStrategy.None; + } + + private bool HasCopyConstructor(INamedTypeSymbol typeSymbol) + { + return typeSymbol.Constructors.Any(c => + c.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(c.Parameters[0].Type, typeSymbol)); + } + + private bool HasParameterlessConstructor(INamedTypeSymbol typeSymbol) + { + // Structs always have an implicit parameterless constructor + if (typeSymbol.TypeKind == TypeKind.Struct) + return true; + + return typeSymbol.Constructors.Any(c => + c.Parameters.Length == 0 && + c.DeclaredAccessibility >= Accessibility.Internal); + } + + private List GetMembersForClone( + INamedTypeSymbol typeSymbol, + PrototypeConfig config, + SourceProductionContext context) + { + var members = new List(); + var includeAll = !config.IncludeExplicit; + + // Get all instance properties and fields + var candidateMembers = typeSymbol.GetMembers() + .Where(m => (m is IPropertySymbol || m is IFieldSymbol) && + !m.IsStatic && + m.DeclaredAccessibility == Accessibility.Public); + + foreach (var member in candidateMembers) + { + // Check for attributes + var hasIgnore = HasAttribute(member, "PatternKit.Generators.Prototype.PrototypeIgnoreAttribute"); + var hasInclude = HasAttribute(member, "PatternKit.Generators.Prototype.PrototypeIncludeAttribute"); + var strategyAttr = GetAttribute(member, "PatternKit.Generators.Prototype.PrototypeStrategyAttribute"); + + // Check for attribute misuse + if (includeAll && hasInclude) + { + context.ReportDiagnostic(Diagnostic.Create( + AttributeMisuseDescriptor, + member.Locations.FirstOrDefault(), + $"[PrototypeInclude] on member '{member.Name}' has no effect when IncludeExplicit is false. Remove the attribute or set IncludeExplicit=true.")); + } + else if (!includeAll && hasIgnore) + { + context.ReportDiagnostic(Diagnostic.Create( + AttributeMisuseDescriptor, + member.Locations.FirstOrDefault(), + $"[PrototypeIgnore] on member '{member.Name}' has no effect when IncludeExplicit is true. Remove the attribute or set IncludeExplicit=false.")); + } + + // Determine if this member should be included + bool shouldInclude = includeAll ? !hasIgnore : hasInclude; + if (!shouldInclude) + continue; + + // Extract member type and characteristics + ITypeSymbol? memberType = null; + bool isReadOnly = false; + bool isInitOnly = false; + + if (member is IPropertySymbol prop) + { + // Must have a getter + if (prop.GetMethod is null || prop.GetMethod.DeclaredAccessibility != Accessibility.Public) + continue; + + memberType = prop.Type; + isReadOnly = prop.SetMethod is null; + isInitOnly = prop.SetMethod?.IsInitOnly ?? false; + } + else if (member is IFieldSymbol fld) + { + memberType = fld.Type; + isReadOnly = fld.IsReadOnly; + } + + if (memberType is null) + continue; + + // Determine clone strategy + var strategy = DetermineCloneStrategy(member, memberType, strategyAttr, config, context); + if (strategy is null) + continue; // Error already reported + + members.Add(new MemberInfo + { + Name = member.Name, + Type = memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + TypeSymbol = memberType, + IsProperty = member is IPropertySymbol, + IsField = member is IFieldSymbol, + IsReadOnly = isReadOnly, + IsInitOnly = isInitOnly, + CloneStrategy = strategy.Value, + Symbol = member + }); + } + + // Sort by member name for deterministic output + members.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name)); + + return members; + } + + private CloneStrategy? DetermineCloneStrategy( + ISymbol member, + ITypeSymbol memberType, + AttributeData? strategyAttr, + PrototypeConfig config, + SourceProductionContext context) + { + // If explicit strategy provided, validate and use it + if (strategyAttr is not null) + { + var ctorArg = strategyAttr.ConstructorArguments.FirstOrDefault(); + if (ctorArg.Value is int strategyValue) + { + var strategy = (CloneStrategy)strategyValue; + + // Validate strategy + if (strategy == CloneStrategy.Clone) + { + if (!HasCloneMechanism(memberType)) + { + context.ReportDiagnostic(Diagnostic.Create( + CloneMechanismMissingDescriptor, + member.Locations.FirstOrDefault(), + member.Name)); + return null; + } + } + else if (strategy == CloneStrategy.Custom) + { + // Check for custom partial method + var containingType = member.ContainingType; + var methodName = $"Clone{member.Name}"; + var hasCustomMethod = containingType.GetMembers(methodName) + .OfType() + .Any(m => m.IsStatic && m.IsPartialDefinition && m.Parameters.Length == 1); + + if (!hasCustomMethod) + { + context.ReportDiagnostic(Diagnostic.Create( + CustomStrategyMissingDescriptor, + member.Locations.FirstOrDefault(), + member.Name, + memberType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + return null; + } + } + else if (strategy == CloneStrategy.DeepCopy) + { + // DeepCopy is v2 - not yet supported + var deepCopyNotImplementedDescriptor = new DiagnosticDescriptor( + id: "PKPRO999", + title: "DeepCopy strategy not yet implemented", + messageFormat: "DeepCopy strategy for member '{0}' is not yet implemented. Use Clone or Custom strategy instead.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + context.ReportDiagnostic(Diagnostic.Create( + deepCopyNotImplementedDescriptor, + member.Locations.FirstOrDefault(), + member.Name)); + return null; + } + + return strategy; + } + } + + // Otherwise, infer based on mode + return InferCloneStrategy(member, memberType, config, context); + } + + private CloneStrategy InferCloneStrategy( + ISymbol member, + ITypeSymbol memberType, + PrototypeConfig config, + SourceProductionContext context) + { + // Value types and string: always copy (safe) + if (memberType.IsValueType || memberType.SpecialType == SpecialType.System_String) + return CloneStrategy.ByReference; + + // Reference types depend on mode + switch (config.Mode) + { + case 0: // ShallowWithWarnings + // Warn about mutable reference types + if (!IsImmutableReferenceType(memberType)) + { + context.ReportDiagnostic(Diagnostic.Create( + UnsafeReferenceCaptureDescriptor, + member.Locations.FirstOrDefault(), + member.Name)); + } + return CloneStrategy.ByReference; + + case 1: // Shallow + return CloneStrategy.ByReference; + + case 2: // DeepWhenPossible + if (HasCloneMechanism(memberType)) + return CloneStrategy.Clone; + return CloneStrategy.ByReference; + + default: + return CloneStrategy.ByReference; + } + } + + private bool IsImmutableReferenceType(ITypeSymbol type) + { + // String is immutable + if (type.SpecialType == SpecialType.System_String) + return true; + + // Value types are always safe (but caller should check for this first) + if (type.IsValueType) + return true; + + // Check for known immutable collections (basic check) + var typeName = type.ToDisplayString(); + if (typeName.StartsWith("System.Collections.Immutable.")) + return true; + + // Conservative: assume mutable + return false; + } + + private bool HasCloneMechanism(ITypeSymbol type) + { + // Check for ICloneable + if (ImplementsICloneable(type)) + return true; + + // Check for Clone() method returning same type + var cloneMethod = type.GetMembers("Clone") + .OfType() + .FirstOrDefault(m => m.Parameters.Length == 0 && + SymbolEqualityComparer.Default.Equals(m.ReturnType, type)); + if (cloneMethod is not null) + return true; + + // Check for copy constructor + if (type is INamedTypeSymbol namedType) + { + if (HasCopyConstructor(namedType)) + return true; + } + + // Check for List or similar collections with copy constructors + if (IsCollectionWithCopyConstructor(type)) + return true; + + return false; + } + + private bool ImplementsICloneable(ITypeSymbol type) + { + return type.AllInterfaces.Any(i => + i.ToDisplayString() == "System.ICloneable"); + } + + private bool IsCollectionWithCopyConstructor(ITypeSymbol type) + { + if (type is not INamedTypeSymbol namedType) + return false; + + var typeName = namedType.ConstructedFrom.ToDisplayString(); + + // List, HashSet, Queue, Stack, etc. + return typeName == "System.Collections.Generic.List" || + typeName == "System.Collections.Generic.HashSet" || + typeName == "System.Collections.Generic.Queue" || + typeName == "System.Collections.Generic.Stack" || + typeName == "System.Collections.Generic.LinkedList" || + typeName == "System.Collections.Generic.Dictionary" || + typeName == "System.Collections.Generic.SortedSet" || + typeName == "System.Collections.Generic.SortedDictionary"; + } + + private static bool HasAttribute(ISymbol symbol, string attributeName) + { + return symbol.GetAttributes().Any(a => + a.AttributeClass?.ToDisplayString() == attributeName); + } + + private static AttributeData? GetAttribute(ISymbol symbol, string attributeName) + { + return symbol.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == attributeName); + } + + private string GenerateCloneMethod(TypeInfo typeInfo, PrototypeConfig config, SourceProductionContext context) + { + var sb = new StringBuilder(); + sb.AppendLine("#nullable enable"); + sb.AppendLine("// "); + sb.AppendLine(); + + // Only add namespace declaration if not in global namespace + if (!string.IsNullOrEmpty(typeInfo.Namespace)) + { + sb.AppendLine($"namespace {typeInfo.Namespace};"); + sb.AppendLine(); + } + + // Determine type declaration keyword + string typeKeyword; + if (typeInfo.IsRecordClass) + typeKeyword = "partial record class"; + else if (typeInfo.IsRecordStruct) + typeKeyword = "partial record struct"; + else if (typeInfo.IsClass) + typeKeyword = "partial class"; + else + typeKeyword = "partial struct"; + + sb.AppendLine($"{typeKeyword} {typeInfo.TypeName}"); + sb.AppendLine("{"); + + // Generate custom clone hook declarations + GenerateCustomCloneHooks(sb, typeInfo); + + // Generate Clone method + GenerateCloneMethodBody(sb, typeInfo, config, context); + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private void GenerateCustomCloneHooks(StringBuilder sb, TypeInfo typeInfo) + { + var customMembers = typeInfo.Members.Where(m => m.CloneStrategy == CloneStrategy.Custom).ToList(); + if (customMembers.Count == 0) + return; + + foreach (var member in customMembers) + { + sb.AppendLine($" /// Custom clone hook for {member.Name}."); + sb.AppendLine($" private static partial {member.Type} Clone{member.Name}({member.Type} value);"); + sb.AppendLine(); + } + } + + private void GenerateCloneMethodBody(StringBuilder sb, TypeInfo typeInfo, PrototypeConfig config, SourceProductionContext context) + { + sb.AppendLine($" /// Creates a clone of this instance."); + sb.AppendLine($" public {typeInfo.TypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)} {config.CloneMethodName}()"); + sb.AppendLine(" {"); + + // Generate member cloning expressions + var cloneExprs = new Dictionary(); + foreach (var member in typeInfo.Members) + { + var expr = GenerateCloneExpression(member, typeInfo, context); + cloneExprs[member.Name] = expr; + } + + // Generate construction based on strategy + switch (typeInfo.ConstructionStrategy) + { + case ConstructionStrategy.RecordWith: + GenerateRecordWithConstruction(sb, typeInfo, cloneExprs); + break; + + case ConstructionStrategy.CopyConstructor: + GenerateCopyConstructorConstruction(sb, typeInfo, cloneExprs); + break; + + case ConstructionStrategy.ParameterlessConstructor: + GenerateParameterlessConstructorConstruction(sb, typeInfo, cloneExprs); + break; + } + + sb.AppendLine(" }"); + } + + private string GenerateCloneExpression(MemberInfo member, TypeInfo typeInfo, SourceProductionContext context) + { + switch (member.CloneStrategy) + { + case CloneStrategy.ByReference: + return $"this.{member.Name}"; + + case CloneStrategy.ShallowCopy: + return GenerateShallowCopyExpression(member); + + case CloneStrategy.Clone: + return GenerateCloneCallExpression(member); + + case CloneStrategy.Custom: + return $"Clone{member.Name}(this.{member.Name})"; + + default: + return $"this.{member.Name}"; + } + } + + private string GenerateShallowCopyExpression(MemberInfo member) + { + var typeName = member.TypeSymbol.ToDisplayString(); + + // For collections, create a new collection with the same elements + if (IsCollectionWithCopyConstructor(member.TypeSymbol)) + { + return $"new {member.Type}(this.{member.Name})"; + } + + // For arrays + if (member.TypeSymbol is IArrayTypeSymbol) + { + return $"(({member.Type})this.{member.Name}.Clone())"; + } + + // Default: just copy reference + return $"this.{member.Name}"; + } + + private string GenerateCloneCallExpression(MemberInfo member) + { + // Check for ICloneable + if (ImplementsICloneable(member.TypeSymbol)) + { + return $"({member.Type})this.{member.Name}.Clone()"; + } + + // Check for Clone() method + var cloneMethod = member.TypeSymbol.GetMembers("Clone") + .OfType() + .FirstOrDefault(m => m.Parameters.Length == 0); + if (cloneMethod is not null) + { + return $"this.{member.Name}.Clone()"; + } + + // Check for copy constructor + if (member.TypeSymbol is INamedTypeSymbol namedType && HasCopyConstructor(namedType)) + { + return $"new {member.Type}(this.{member.Name})"; + } + + // For collections with copy constructors + if (IsCollectionWithCopyConstructor(member.TypeSymbol)) + { + return $"new {member.Type}(this.{member.Name})"; + } + + // Fallback + return $"this.{member.Name}"; + } + + private void GenerateRecordWithConstruction(StringBuilder sb, TypeInfo typeInfo, Dictionary cloneExprs) + { + // For records with init properties, use with-expression + sb.Append(" return this with { "); + + var assignments = typeInfo.Members + .Where(m => !m.IsReadOnly) // Can't set readonly members in with-expression + .Select(m => $"{m.Name} = {cloneExprs[m.Name]}") + .ToList(); + + if (assignments.Count > 0) + { + sb.Append(string.Join(", ", assignments)); + } + + sb.AppendLine(" };"); + } + + private void GenerateCopyConstructorConstruction(StringBuilder sb, TypeInfo typeInfo, Dictionary cloneExprs) + { + // Create using copy constructor, then set members that need custom cloning + sb.AppendLine($" var clone = new {typeInfo.TypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}(this);"); + + // Override members that need special cloning + foreach (var member in typeInfo.Members.Where(m => m.CloneStrategy != CloneStrategy.ByReference && !m.IsReadOnly && !m.IsInitOnly)) + { + sb.AppendLine($" clone.{member.Name} = {cloneExprs[member.Name]};"); + } + + sb.AppendLine(" return clone;"); + } + + private void GenerateParameterlessConstructorConstruction(StringBuilder sb, TypeInfo typeInfo, Dictionary cloneExprs) + { + // Use object initializer syntax if possible + var settableMembers = typeInfo.Members.Where(m => !m.IsReadOnly && !m.IsInitOnly).ToList(); + + if (settableMembers.Count > 0) + { + sb.AppendLine($" return new {typeInfo.TypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}"); + sb.AppendLine(" {"); + foreach (var member in settableMembers) + { + sb.AppendLine($" {member.Name} = {cloneExprs[member.Name]},"); + } + sb.AppendLine(" };"); + } + else + { + sb.AppendLine($" return new {typeInfo.TypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}();"); + } + } + + // Helper classes + private class PrototypeConfig + { + public int Mode { get; set; } // 0=ShallowWithWarnings, 1=Shallow, 2=DeepWhenPossible + public string CloneMethodName { get; set; } = "Clone"; + public bool IncludeExplicit { get; set; } + } + + private enum ConstructionStrategy + { + None, + RecordWith, + CopyConstructor, + ParameterlessConstructor + } + + private enum CloneStrategy + { + ByReference = 0, + ShallowCopy = 1, + Clone = 2, + DeepCopy = 3, + Custom = 4 + } + + private class TypeInfo + { + public INamedTypeSymbol TypeSymbol { get; set; } = null!; + public string TypeName { get; set; } = ""; + public string Namespace { get; set; } = ""; + public bool IsClass { get; set; } + public bool IsStruct { get; set; } + public bool IsRecordClass { get; set; } + public bool IsRecordStruct { get; set; } + public List Members { get; set; } = new(); + public ConstructionStrategy ConstructionStrategy { get; set; } + } + + private class MemberInfo + { + public string Name { get; set; } = ""; + public string Type { get; set; } = ""; + public ITypeSymbol TypeSymbol { get; set; } = null!; + public bool IsProperty { get; set; } + public bool IsField { get; set; } + public bool IsReadOnly { get; set; } + public bool IsInitOnly { get; set; } + public CloneStrategy CloneStrategy { get; set; } + public ISymbol Symbol { get; set; } = null!; + } +} From 7dfc87f5e2c3041f55e545f945e92c7007518748 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:00:25 +0000 Subject: [PATCH 03/14] Address code review feedback: improve type safety and code consistency Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../AnalyzerReleases.Unshipped.md | 1 + .../PrototypeGenerator.cs | 47 +++++++++++++------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index c8131a7..b53c207 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -58,4 +58,5 @@ PKPRO003 | PatternKit.Generators.Prototype | Warning | Unsafe reference capture PKPRO004 | PatternKit.Generators.Prototype | Error | Requested Clone strategy but no clone mechanism found PKPRO005 | PatternKit.Generators.Prototype | Error | Custom strategy requires partial clone hook, but none found PKPRO006 | PatternKit.Generators.Prototype | Warning | Include/Ignore attribute misuse +PKPRO999 | PatternKit.Generators.Prototype | Error | DeepCopy strategy not yet implemented diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index 208ab82..c423052 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -21,6 +21,7 @@ public sealed class PrototypeGenerator : IIncrementalGenerator private const string DiagIdCloneMechanismMissing = "PKPRO004"; private const string DiagIdCustomStrategyMissing = "PKPRO005"; private const string DiagIdAttributeMisuse = "PKPRO006"; + private const string DiagIdDeepCopyNotImplemented = "PKPRO999"; private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( id: DiagIdTypeNotPartial, @@ -70,6 +71,14 @@ public sealed class PrototypeGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor DeepCopyNotImplementedDescriptor = new( + id: DiagIdDeepCopyNotImplemented, + title: "DeepCopy strategy not yet implemented", + messageFormat: "DeepCopy strategy for member '{0}' is not yet implemented. Use Clone or Custom strategy instead.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Find all type declarations with [Prototype] attribute @@ -147,7 +156,7 @@ private PrototypeConfig ParsePrototypeConfig(AttributeData attribute) switch (named.Key) { case "Mode": - config.Mode = (int)named.Value.Value!; + config.Mode = (PrototypeMode)(int)named.Value.Value!; break; case "CloneMethodName": config.CloneMethodName = (string)named.Value.Value!; @@ -203,7 +212,17 @@ private ConstructionStrategy DetermineConstructionStrategy(INamedTypeSymbol type // For records, prefer with-expression if all members are init/readonly if (typeInfo.IsRecordClass || typeInfo.IsRecordStruct) { - bool allInit = typeInfo.Members.All(m => m.IsInitOnly || m.IsReadOnly); + // Check if all members are init-only or readonly + bool allInit = true; + foreach (var member in typeInfo.Members) + { + if (!member.IsInitOnly && !member.IsReadOnly) + { + allInit = false; + break; + } + } + if (allInit) return ConstructionStrategy.RecordWith; @@ -385,15 +404,8 @@ private List GetMembersForClone( else if (strategy == CloneStrategy.DeepCopy) { // DeepCopy is v2 - not yet supported - var deepCopyNotImplementedDescriptor = new DiagnosticDescriptor( - id: "PKPRO999", - title: "DeepCopy strategy not yet implemented", - messageFormat: "DeepCopy strategy for member '{0}' is not yet implemented. Use Clone or Custom strategy instead.", - category: "PatternKit.Generators.Prototype", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); context.ReportDiagnostic(Diagnostic.Create( - deepCopyNotImplementedDescriptor, + DeepCopyNotImplementedDescriptor, member.Locations.FirstOrDefault(), member.Name)); return null; @@ -420,7 +432,7 @@ private CloneStrategy InferCloneStrategy( // Reference types depend on mode switch (config.Mode) { - case 0: // ShallowWithWarnings + case PrototypeMode.ShallowWithWarnings: // Warn about mutable reference types if (!IsImmutableReferenceType(memberType)) { @@ -431,10 +443,10 @@ private CloneStrategy InferCloneStrategy( } return CloneStrategy.ByReference; - case 1: // Shallow + case PrototypeMode.Shallow: return CloneStrategy.ByReference; - case 2: // DeepWhenPossible + case PrototypeMode.DeepWhenPossible: if (HasCloneMechanism(memberType)) return CloneStrategy.Clone; return CloneStrategy.ByReference; @@ -743,11 +755,18 @@ private void GenerateParameterlessConstructorConstruction(StringBuilder sb, Type // Helper classes private class PrototypeConfig { - public int Mode { get; set; } // 0=ShallowWithWarnings, 1=Shallow, 2=DeepWhenPossible + public PrototypeMode Mode { get; set; } = PrototypeMode.ShallowWithWarnings; public string CloneMethodName { get; set; } = "Clone"; public bool IncludeExplicit { get; set; } } + private enum PrototypeMode + { + ShallowWithWarnings = 0, + Shallow = 1, + DeepWhenPossible = 2 + } + private enum ConstructionStrategy { None, From 36ed5250276718e1ed6d51c71419abca38a89f30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:05:44 +0000 Subject: [PATCH 04/14] Fix record type support and add comprehensive tests Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../PrototypeGenerator.cs | 8 + .../PrototypeGeneratorTests.cs | 306 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index c423052..ff60a64 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -127,6 +127,12 @@ private void GeneratePrototypeForType( if (typeInfo is null) return; + // For records, default to "Duplicate" instead of "Clone" (which is reserved) + if (!config.CloneMethodNameExplicit && (typeInfo.IsRecordClass || typeInfo.IsRecordStruct)) + { + config.CloneMethodName = "Duplicate"; + } + // Generate clone method var cloneSource = GenerateCloneMethod(typeInfo, config, context); if (!string.IsNullOrEmpty(cloneSource)) @@ -160,6 +166,7 @@ private PrototypeConfig ParsePrototypeConfig(AttributeData attribute) break; case "CloneMethodName": config.CloneMethodName = (string)named.Value.Value!; + config.CloneMethodNameExplicit = true; break; case "IncludeExplicit": config.IncludeExplicit = (bool)named.Value.Value!; @@ -757,6 +764,7 @@ private class PrototypeConfig { public PrototypeMode Mode { get; set; } = PrototypeMode.ShallowWithWarnings; public string CloneMethodName { get; set; } = "Clone"; + public bool CloneMethodNameExplicit { get; set; } public bool IncludeExplicit { get; set; } } diff --git a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs new file mode 100644 index 0000000..fca1083 --- /dev/null +++ b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs @@ -0,0 +1,306 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Common; + +namespace PatternKit.Generators.Tests; + +public class PrototypeGeneratorTests +{ + [Fact] + public void GenerateCloneForClass() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class Person + { + public string Name { get; set; } = ""; + public int Age { get; set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneForClass)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Clone method is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("Person.Prototype.g.cs", names); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void GenerateCloneForRecordClass() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial record class Person(string Name, int Age); + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneForRecordClass)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Clone method is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("Person.Prototype.g.cs", names); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Records get "Duplicate" method by default + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Person.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Duplicate()", generatedSource); + } + + [Fact] + public void GenerateCloneForRecordStruct() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial record struct Point(int X, int Y); + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneForRecordStruct)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Clone method is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("Point.Prototype.g.cs", names); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Records get "Duplicate" method by default + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Point.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Duplicate()", generatedSource); + } + + [Fact] + public void GenerateCloneForStruct() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial struct Vector + { + public double X { get; set; } + public double Y { get; set; } + public double Z { get; set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneForStruct)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Clone method is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("Vector.Prototype.g.cs", names); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void ErrorIfNotPartial() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public class NonPartialClass + { + public int Value { get; set; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorIfNotPartial)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO001 diagnostic + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO001"); + } + + [Fact] + public void GenerateCloneWithCustomMethodName() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype(CloneMethodName = "Duplicate")] + public partial class Item + { + public string Name { get; set; } = ""; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithCustomMethodName)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Clone method is generated + var names = result.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("Item.Prototype.g.cs", names); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that the custom method name is used + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Item.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Duplicate()", generatedSource); + Assert.DoesNotContain("Clone()", generatedSource); + } + + [Fact] + public void GenerateCloneWithIgnoreAttribute() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class User + { + public string Username { get; set; } = ""; + + [PrototypeIgnore] + public string Password { get; set; } = ""; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithIgnoreAttribute)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that Password is not cloned + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "User.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Username", generatedSource); + Assert.DoesNotContain("Password", generatedSource); + } + + [Fact] + public void GenerateCloneWithExplicitInclude() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype(IncludeExplicit = true)] + public partial class Config + { + [PrototypeInclude] + public string ApiKey { get; set; } = ""; + + public string Internal { get; set; } = ""; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithExplicitInclude)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No generator diagnostics + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics)); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that only ApiKey is cloned + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Config.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("ApiKey", generatedSource); + Assert.DoesNotContain("Internal", generatedSource); + } + + [Fact] + public void WarnOnMutableReferenceType() + { + const string source = """ + using PatternKit.Generators.Prototype; + using System.Collections.Generic; + + namespace TestNamespace; + + [Prototype] + public partial class Container + { + public List Items { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(WarnOnMutableReferenceType)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO003 warning for mutable reference type + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO003" && d.Severity == DiagnosticSeverity.Warning); + } +} From 74cb964e76f915fc74b1944f84acf137f669b539 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:07:40 +0000 Subject: [PATCH 05/14] Final code review fixes: improve null safety and remove unused code Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- src/PatternKit.Generators/PrototypeGenerator.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index ff60a64..7a3f7e5 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -162,14 +162,19 @@ private PrototypeConfig ParsePrototypeConfig(AttributeData attribute) switch (named.Key) { case "Mode": - config.Mode = (PrototypeMode)(int)named.Value.Value!; + if (named.Value.Value is int modeValue) + config.Mode = (PrototypeMode)modeValue; break; case "CloneMethodName": - config.CloneMethodName = (string)named.Value.Value!; - config.CloneMethodNameExplicit = true; + if (named.Value.Value is string methodName) + { + config.CloneMethodName = methodName; + config.CloneMethodNameExplicit = true; + } break; case "IncludeExplicit": - config.IncludeExplicit = (bool)named.Value.Value!; + if (named.Value.Value is bool includeExplicit) + config.IncludeExplicit = includeExplicit; break; } } @@ -655,8 +660,6 @@ private string GenerateCloneExpression(MemberInfo member, TypeInfo typeInfo, Sou private string GenerateShallowCopyExpression(MemberInfo member) { - var typeName = member.TypeSymbol.ToDisplayString(); - // For collections, create a new collection with the same elements if (IsCollectionWithCopyConstructor(member.TypeSymbol)) { From acf5b81f8f9884328e42c0ade103a4a041b4690b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:13:38 +0000 Subject: [PATCH 06/14] docs: Add comprehensive documentation for Prototype pattern generator - Created docs/generators/prototype.md with complete generator documentation - Created docs/examples/prototype-demo.md with real-world game character example - Follows existing PatternKit documentation patterns and style - Includes quickstart, configuration, diagnostics, best practices - Demonstrates deep cloning, mutation chains, and prototype registry Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/examples/prototype-demo.md | 844 ++++++++++++++++++++++++++++ docs/generators/prototype.md | 969 ++++++++++++++++++++++++++++++++ 2 files changed, 1813 insertions(+) create mode 100644 docs/examples/prototype-demo.md create mode 100644 docs/generators/prototype.md diff --git a/docs/examples/prototype-demo.md b/docs/examples/prototype-demo.md new file mode 100644 index 0000000..a9fe03b --- /dev/null +++ b/docs/examples/prototype-demo.md @@ -0,0 +1,844 @@ +# Game Character Factory with Prototype Pattern + +This demo shows how to build an efficient game entity spawning system using the Prototype pattern, demonstrating complex object cloning with nested structures, collections, and runtime mutations. + +## What it demonstrates + +- **Prototype registry** - store and retrieve named character templates +- **Deep cloning** - create independent copies of complex objects +- **Mutation chains** - customize clones at creation time +- **Performance** - avoid expensive initialization via cloning +- **Independence verification** - clones don't affect originals + +## Where to look + +- Code: `src/PatternKit.Examples/PrototypeDemo/PrototypeDemo.cs` +- Tests: `test/PatternKit.Tests/Creational/Prototype/PrototypeTests.cs` +- Generator: [Prototype Generator Documentation](../generators/prototype.md) + +## Quick start + +```csharp +using PatternKit.Creational.Prototype; + +// Define your domain model +public sealed class GameCharacter +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Class { get; set; } + public int Level { get; set; } = 1; + public CharacterStats Stats { get; set; } = new(); + public Equipment Equipment { get; set; } = new(); + + public static GameCharacter DeepClone(in GameCharacter source) => new() + { + Id = $"{source.Id}-{Guid.NewGuid():N}"[..16], + Name = source.Name, + Class = source.Class, + Level = source.Level, + Stats = source.Stats.Clone(), + Equipment = source.Equipment.Clone() + }; +} + +// Create a prototype registry +var factory = Prototype + .Create() + .Map("warrior", CreateWarriorPrototype(), GameCharacter.DeepClone) + .Map("mage", CreateMagePrototype(), GameCharacter.DeepClone) + .Build(); + +// Clone with optional mutations +var warrior = factory.Create("warrior"); +var customWarrior = factory.Create("warrior", c => +{ + c.Name = "Sir Galahad"; + c.Level = 25; +}); +``` + +## The Problem + +In game development, you often need to spawn many similar entities (NPCs, enemies, projectiles) with: + +1. **Complex initialization** - nested objects (stats, equipment, abilities) +2. **Expensive setup** - loading assets, computing derived values +3. **Variations needed** - similar but not identical instances +4. **Performance critical** - spawn hundreds per frame + +Traditional solutions have drawbacks: + +```csharp +// ❌ Factory functions - run full initialization every time +public GameCharacter CreateWarrior() +{ + return new GameCharacter + { + Id = Guid.NewGuid().ToString(), + Name = "Warrior", + Stats = new CharacterStats { Health = 150, Strength = 15, /* ... */ }, + Equipment = new Equipment { Weapon = "Sword", /* ... */ }, + Abilities = ["Slash", "Block", "Charge"], + // ... expensive initialization + }; +} + +// ❌ Inheritance - class explosion for variants +public class Warrior : GameCharacter { } +public class EliteWarrior : Warrior { } +public class BossWarrior : Warrior { } +// ... one class per variant +``` + +## The Solution: Prototype Pattern + +The Prototype pattern solves this by: + +1. **Pre-configuring base prototypes** once +2. **Cloning them** (cheap memory copy) +3. **Mutating the clone** as needed + +```csharp +// ✅ Configure once +var warriorPrototype = CreateWarriorPrototype(); + +// ✅ Clone many times (fast) +var w1 = warriorPrototype.Clone(); +var w2 = warriorPrototype.Clone(); + +// ✅ Customize each clone +w1.Name = "Goblin Fighter #1"; +w2.Name = "Goblin Fighter #2"; +``` + +## Domain Model + +### Character Statistics + +```csharp +public sealed class CharacterStats +{ + public int Health { get; set; } + public int Mana { get; set; } + public int Strength { get; set; } + public int Agility { get; set; } + public int Intelligence { get; set; } + + public CharacterStats Clone() => new() + { + Health = Health, + Mana = Mana, + Strength = Strength, + Agility = Agility, + Intelligence = Intelligence + }; +} +``` + +### Equipment System + +```csharp +public sealed class Equipment +{ + public string Weapon { get; set; } = "Fists"; + public string Armor { get; set; } = "Cloth"; + public List Accessories { get; set; } = []; + + public Equipment Clone() => new() + { + Weapon = Weapon, + Armor = Armor, + Accessories = new List(Accessories) // New list, same items + }; +} +``` + +### Game Character + +```csharp +public sealed class GameCharacter +{ + public required string Id { get; set; } + public required string Name { get; set; } + public required string Class { get; set; } + public int Level { get; set; } = 1; + public CharacterStats Stats { get; set; } = new(); + public Equipment Equipment { get; set; } = new(); + public List Abilities { get; set; } = []; + public Dictionary Resistances { get; set; } = new(); + + // Deep clone - recursively clones all nested objects + public static GameCharacter DeepClone(in GameCharacter source) => new() + { + Id = $"{source.Id}-{Guid.NewGuid():N}"[..16], // Generate new ID + Name = source.Name, + Class = source.Class, + Level = source.Level, + Stats = source.Stats.Clone(), // Clone nested object + Equipment = source.Equipment.Clone(), // Clone nested object + Abilities = new List(source.Abilities), // Clone collection + Resistances = new Dictionary(source.Resistances) // Clone dict + }; +} +``` + +**Key points:** +- `DeepClone` creates truly independent copies +- Each nested object is also cloned (not just the reference) +- Collections are copied (new collection, elements may be shared if immutable) +- IDs are regenerated to ensure uniqueness + +## Base Prototypes + +### Warrior Prototype + +```csharp +public static GameCharacter CreateWarriorPrototype() => new() +{ + Id = "warrior-base", + Name = "Warrior", + Class = "Warrior", + Level = 1, + Stats = new CharacterStats + { + Health = 150, + Mana = 30, + Strength = 15, + Agility = 8, + Intelligence = 5 + }, + Equipment = new Equipment + { + Weapon = "Iron Sword", + Armor = "Chainmail", + Accessories = ["Shield"] + }, + Abilities = ["Slash", "Block", "Charge"], + Resistances = new Dictionary + { + ["Physical"] = 20, + ["Magic"] = -10 + } +}; +``` + +**Design:** +- High health and strength +- Low intelligence and mana +- Physical resistance, magic weakness + +### Mage Prototype + +```csharp +public static GameCharacter CreateMagePrototype() => new() +{ + Id = "mage-base", + Name = "Mage", + Class = "Mage", + Level = 1, + Stats = new CharacterStats + { + Health = 80, + Mana = 150, + Strength = 5, + Agility = 7, + Intelligence = 18 + }, + Equipment = new Equipment + { + Weapon = "Oak Staff", + Armor = "Silk Robes", + Accessories = ["Spellbook", "Amulet"] + }, + Abilities = ["Fireball", "Ice Shield", "Teleport"], + Resistances = new Dictionary + { + ["Physical"] = -10, + ["Magic"] = 25 + } +}; +``` + +**Design:** +- High mana and intelligence +- Low health and strength +- Magic resistance, physical weakness + +### Rogue Prototype + +```csharp +public static GameCharacter CreateRoguePrototype() => new() +{ + Id = "rogue-base", + Name = "Rogue", + Class = "Rogue", + Level = 1, + Stats = new CharacterStats + { + Health = 100, + Mana = 60, + Strength = 10, + Agility = 16, + Intelligence = 10 + }, + Equipment = new Equipment + { + Weapon = "Twin Daggers", + Armor = "Leather Vest", + Accessories = ["Lockpicks", "Smoke Bombs"] + }, + Abilities = ["Backstab", "Stealth", "Poison"], + Resistances = new Dictionary + { + ["Physical"] = 5, + ["Magic"] = 5, + ["Poison"] = 50 + } +}; +``` + +**Design:** +- High agility for speed/evasion +- Balanced stats +- Poison immunity + +## Prototype Registry Setup + +### Basic Character Factory + +```csharp +public static Prototype CreateCharacterFactory() +{ + return Prototype + .Create() + + // Register base class prototypes + .Map("warrior", CreateWarriorPrototype(), GameCharacter.DeepClone) + .Map("mage", CreateMagePrototype(), GameCharacter.DeepClone) + .Map("rogue", CreateRoguePrototype(), GameCharacter.DeepClone) + + // Register elite variants with pre-configured mutations + .Map("elite-warrior", CreateWarriorPrototype(), GameCharacter.DeepClone) + .Mutate("elite-warrior", c => + { + c.Name = "Elite Warrior"; + c.Level = 10; + c.Stats.Health *= 2; + }) + + .Map("elite-mage", CreateMagePrototype(), GameCharacter.DeepClone) + .Mutate("elite-mage", c => + { + c.Name = "Archmage"; + c.Level = 15; + c.Stats.Mana *= 3; + }) + + // Register boss variant + .Map("boss-dragon-knight", CreateWarriorPrototype(), GameCharacter.DeepClone) + .Mutate("boss-dragon-knight", c => + { + c.Name = "Dragon Knight"; + c.Level = 50; + c.Stats.Health = 5000; + c.Stats.Strength = 80; + c.Equipment.Weapon = "Dragonbone Greatsword"; + c.Equipment.Armor = "Dragon Scale Plate"; + c.Resistances["Fire"] = 100; + }) + + // Set default fallback + .Default(CreateWarriorPrototype(), GameCharacter.DeepClone) + + .Build(); +} +``` + +**Key features:** +- **Named templates** - retrieve by key ("warrior", "mage", etc.) +- **Pre-configured mutations** - elite variants have baked-in transformations +- **Default fallback** - returned if key not found +- **Fluent API** - chain configuration calls + +### Single Prototype Spawner + +For mass-spawning many copies of the same entity: + +```csharp +public static Prototype CreateNpcSpawner(GameCharacter baseNpc) +{ + return Prototype + .Create(baseNpc, GameCharacter.DeepClone) + .Build(); +} +``` + +## Usage Scenarios + +### Scenario 1: Clone Base Classes + +```csharp +var factory = CreateCharacterFactory(); + +var warrior = factory.Create("warrior"); +Console.WriteLine(warrior); +// Output: Warrior (Lv.1 Warrior) - HP:150 MP:30 STR:15 AGI:8 INT:5 + +var mage = factory.Create("mage"); +Console.WriteLine(mage); +// Output: Mage (Lv.1 Mage) - HP:80 MP:150 STR:5 AGI:7 INT:18 +``` + +**What happens:** +1. Registry looks up "warrior" key +2. Calls `GameCharacter.DeepClone()` on the stored prototype +3. Returns independent copy +4. Each clone gets a new unique ID + +### Scenario 2: Clone with Runtime Mutations + +```csharp +var customWarrior = factory.Create("warrior", c => +{ + c.Name = "Sir Galahad"; + c.Level = 25; + c.Stats.Strength += 20; + c.Equipment.Weapon = "Excalibur"; + c.Abilities.Add("Holy Strike"); +}); + +Console.WriteLine(customWarrior); +// Output: Sir Galahad (Lv.25 Warrior) - HP:150 MP:30 STR:35 AGI:8 INT:5 +// Weapon: Excalibur | Armor: Chainmail +// Abilities: Slash, Block, Charge, Holy Strike +``` + +**What happens:** +1. Clone the base "warrior" prototype +2. Apply the mutation lambda to the clone +3. Return the customized instance + +**Benefits:** +- Start from a base template +- Customize only what's needed +- No need to set every property manually + +### Scenario 3: Clone Pre-configured Elite Variants + +```csharp +var eliteWarrior = factory.Create("elite-warrior"); +Console.WriteLine(eliteWarrior); +// Output: Elite Warrior (Lv.10 Warrior) - HP:300 MP:30 STR:15 AGI:8 INT:5 + +var archmage = factory.Create("elite-mage"); +Console.WriteLine(archmage); +// Output: Archmage (Lv.15 Mage) - HP:80 MP:450 STR:5 AGI:7 INT:18 +``` + +**What happens:** +1. Elite variants are stored with pre-applied mutations +2. When you clone "elite-warrior", you get the mutated version +3. No runtime mutation cost - it's baked into the prototype + +**When to use:** +- Common variations you spawn frequently +- Avoid repeating the same mutations +- Performance optimization + +### Scenario 4: Clone Boss Characters + +```csharp +var boss = factory.Create("boss-dragon-knight"); +Console.WriteLine(boss); +// Output: Dragon Knight (Lv.50 Warrior) - HP:5000 MP:30 STR:80 AGI:8 INT:5 +// Weapon: Dragonbone Greatsword | Armor: Dragon Scale Plate +// Resistances: Physical:20%, Magic:-10%, Fire:100% +``` + +**Design pattern:** +- Bosses are heavily modified variants +- Start from a base class prototype (Warrior) +- Transform stats, equipment, abilities +- Still benefit from cloning mechanism + +### Scenario 5: Mass Spawn NPCs + +```csharp +// Create a single prototype +var goblinPrototype = new GameCharacter +{ + Id = "goblin", + Name = "Goblin", + Class = "Monster", + Level = 3, + Stats = new CharacterStats + { + Health = 30, + Mana = 10, + Strength = 8, + Agility = 12, + Intelligence = 3 + }, + Equipment = new Equipment { Weapon = "Rusty Dagger", Armor = "Rags" }, + Abilities = ["Scratch", "Flee"] +}; + +var spawner = CreateNpcSpawner(goblinPrototype); + +// Spawn many with slight variations +Console.WriteLine("Spawning 5 goblins with variations..."); +for (int i = 0; i < 5; i++) +{ + var goblin = spawner.Create(g => + { + g.Name = $"Goblin #{i + 1}"; + g.Stats.Health += Random.Shared.Next(-5, 10); // Randomize + }); + Console.WriteLine($"[{goblin.Id[..8]}] {goblin.Name} HP:{goblin.Stats.Health}"); +} + +// Output: +// [a7b2c3d4] Goblin #1 HP:27 +// [e5f6g7h8] Goblin #2 HP:35 +// [i9j0k1l2] Goblin #3 HP:32 +// [m3n4o5p6] Goblin #4 HP:29 +// [q7r8s9t0] Goblin #5 HP:38 +``` + +**Performance benefits:** +- Clone operation is O(1) for value types, O(n) for collections +- No expensive initialization logic +- No asset loading or computation +- Can spawn hundreds per frame + +### Scenario 6: Verify Clone Independence + +```csharp +var original = factory.Create("warrior"); +var clone = factory.Create("warrior", c => c.Name = "Clone Warrior"); + +Console.WriteLine($"Original: {original.Name}"); // "Warrior" +Console.WriteLine($"Clone: {clone.Name}"); // "Clone Warrior" +Console.WriteLine($"Are same object? {ReferenceEquals(original, clone)}"); // False +Console.WriteLine($"Original unchanged? {original.Name == "Warrior"}"); // True + +// Verify deep independence +clone.Stats.Health = 999; +Console.WriteLine($"Original health: {original.Stats.Health}"); // Still 150 +Console.WriteLine($"Clone health: {clone.Stats.Health}"); // 999 + +clone.Abilities.Add("New Ability"); +Console.WriteLine($"Original abilities: {original.Abilities.Count}"); // Still 3 +Console.WriteLine($"Clone abilities: {clone.Abilities.Count}"); // 4 +``` + +**What this proves:** +- Clones are separate instances (different references) +- Mutations to the clone don't affect the original +- Nested objects (Stats, Equipment) are also independent +- Collections (Abilities, Resistances) are independent + +## Before/After Comparison + +### Before: Manual Creation + +```csharp +// ❌ Manual creation - verbose, error-prone, expensive +public void SpawnEnemies() +{ + for (int i = 0; i < 100; i++) + { + var goblin = new GameCharacter + { + Id = Guid.NewGuid().ToString(), + Name = $"Goblin {i}", + Class = "Monster", + Level = 3, + Stats = new CharacterStats + { + Health = 30, + Mana = 10, + Strength = 8, + Agility = 12, + Intelligence = 3 + }, + Equipment = new Equipment + { + Weapon = "Rusty Dagger", + Armor = "Rags", + Accessories = [] + }, + Abilities = ["Scratch", "Flee"], + Resistances = new Dictionary() + }; + + // ... expensive initialization + LoadAssets(goblin); + ComputeDerivedStats(goblin); + + _entities.Add(goblin); + } +} +``` + +**Problems:** +- 🐌 **Slow** - runs full initialization 100 times +- 📝 **Verbose** - lots of repetitive code +- 🐛 **Error-prone** - easy to miss a property +- 🚫 **Not reusable** - can't easily create variants + +### After: Prototype Pattern + +```csharp +// ✅ Prototype pattern - fast, concise, flexible +public void SpawnEnemies() +{ + // Configure prototype once + var goblinPrototype = CreateGoblinPrototype(); + LoadAssets(goblinPrototype); // Load once + ComputeDerivedStats(goblinPrototype); // Compute once + + var spawner = CreateNpcSpawner(goblinPrototype); + + // Clone 100 times (fast) + for (int i = 0; i < 100; i++) + { + var goblin = spawner.Create(g => g.Name = $"Goblin {i}"); + _entities.Add(goblin); + } +} +``` + +**Benefits:** +- ⚡ **Fast** - clone is much cheaper than initialization +- 📦 **Concise** - configuration in one place +- ✅ **Safe** - single source of truth +- 🔄 **Reusable** - clone with variations easily + +## Pattern Benefits Demonstrated + +### 1. Fast Object Creation + +Cloning is significantly faster than running initialization logic: + +```csharp +// Initialization: O(complexity) +var character = CreateAndInitializeCharacter(); // Slow + +// Cloning: O(size) +var clone = prototype.Create(); // Fast +``` + +**When initialization is expensive:** +- Loading assets from disk +- Database queries +- Complex computations +- Network calls + +**Cloning just copies memory** - much faster. + +### 2. Named Prototype Registry + +Store common configurations with semantic names: + +```csharp +factory.Create("warrior"); // Get a warrior +factory.Create("elite-warrior"); // Get elite variant +factory.Create("boss-dragon-knight"); // Get boss +``` + +**Benefits:** +- **Self-documenting** - name describes what you get +- **Centralized** - all prototypes in one place +- **Easy to extend** - add new variants without changing calling code + +### 3. Mutation Chains + +Customize clones at creation time: + +```csharp +var custom = factory.Create("warrior", c => +{ + c.Name = "Custom Name"; + c.Level = 25; + c.Stats.Strength *= 2; +}); +``` + +**Benefits:** +- **Fluent** - readable customization +- **Flexible** - override any property +- **Composable** - chain multiple mutations + +### 4. Deep Cloning Ensures Independence + +Clones don't share mutable state with the original: + +```csharp +var c1 = factory.Create("warrior"); +var c2 = factory.Create("warrior"); + +c1.Stats.Health = 999; +Console.WriteLine(c2.Stats.Health); // Still 150, not affected +``` + +**Critical for:** +- Game entities (independent behavior) +- Undo/redo systems (save/restore state) +- Concurrent processing (no shared mutations) + +### 5. Runtime Flexibility + +Create variants without defining new classes: + +```csharp +// No need for EliteWarrior, BossWarrior classes +// Just mutate the base prototype + +factory.Map("custom-variant", basePrototype, clone) + .Mutate("custom-variant", c => { /* custom logic */ }); +``` + +**Reduces:** +- Class explosion +- Inheritance hierarchies +- Coupling between types + +## Use Cases + +### Game Development + +- **Enemy spawning** - clone enemy templates +- **Particle systems** - clone particle configurations +- **Item generation** - clone base items with modifications +- **Character presets** - clone character templates + +### Document Processing + +- **Document templates** - clone base documents +- **Form pre-filling** - clone form configurations +- **Report generation** - clone report structures + +### Configuration Management + +- **Environment configs** - clone base configs per environment +- **User preferences** - clone default preferences +- **Feature flags** - clone flag sets + +### Testing + +- **Test fixtures** - clone base test data +- **Mock objects** - clone mock configurations +- **Test scenarios** - clone scenario templates + +## Performance Notes + +### Cloning is Fast + +For the game character example: + +- **Prototype creation:** Once per base type (~5-10 types) +- **Cloning cost:** O(n) where n = number of properties + collection sizes +- **vs. Initialization:** Often 10-100x faster when initialization involves I/O + +### Memory Efficiency + +```csharp +// Prototype stored once in registry +var prototype = CreateWarriorPrototype(); // ~1KB memory + +// Each clone is independent but shallow for immutable data +var clone1 = prototype.Clone(); // ~1KB (deep copy) +var clone2 = prototype.Clone(); // ~1KB (deep copy) + +// Strings are interned, so "Warrior" string is shared (but immutable) +``` + +### When NOT to Use Prototype + +❌ **Simple value objects** - just use constructors: +```csharp +var point = new Point(x: 10, y: 20); // Simpler than cloning +``` + +❌ **Immutable types** - use `with` expressions (records): +```csharp +var p2 = p1 with { X = 20 }; // Built-in cloning +``` + +❌ **Very large objects** - cloning might be expensive: +```csharp +// If object is 100MB, cloning copies 100MB +// Consider lazy loading or copy-on-write strategies +``` + +## Code Generator Support + +The Prototype pattern generator can automate clone method generation: + +```csharp +using PatternKit.Generators.Prototype; + +[Prototype] +public partial class GameCharacter +{ + public required string Id { get; set; } + public required string Name { get; set; } + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public CharacterStats Stats { get; set; } = new(); + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public Equipment Equipment { get; set; } = new(); + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Abilities { get; set; } = []; +} + +// Generated automatically: +// public GameCharacter Clone() { /* implementation */ } +``` + +**Benefits:** +- No manual clone method writing +- Configurable per-member strategies +- Compile-time diagnostics for unsafe cloning +- Supports classes, structs, records + +See [Prototype Generator Documentation](../generators/prototype.md) for details. + +## Key Takeaways + +✅ **Use prototypes when:** +- Object creation is expensive (I/O, computation) +- You need many similar but not identical instances +- Runtime flexibility is important (no new classes) +- You want a registry of common configurations + +✅ **Remember:** +- Deep clone for independence (copy nested objects) +- Shallow clone may share references (understand implications) +- Mutations apply to the clone, not the prototype +- Verify clone independence in tests + +✅ **Performance wins:** +- Initialization once, clone many times +- Memory copy is faster than logic execution +- Registry lookup is O(1) + +## Run the Demo + +```bash +# From the repo root +dotnet build PatternKit.slnx -c Debug +dotnet run --project src/PatternKit.Examples --framework net9.0 + +# Select "Prototype Demo" from the menu +``` + +## Further Reading + +- [Prototype Generator](../generators/prototype.md) - Automated clone method generation +- [PatternKit.Creational.Prototype](../../src/PatternKit/Creational/Prototype/) - Runtime prototype API +- [Gang of Four Design Patterns](https://en.wikipedia.org/wiki/Design_Patterns) - Original pattern catalog diff --git a/docs/generators/prototype.md b/docs/generators/prototype.md new file mode 100644 index 0000000..9242ccb --- /dev/null +++ b/docs/generators/prototype.md @@ -0,0 +1,969 @@ +# Prototype Generator + +The Prototype generator creates GoF-aligned clone methods with configurable strategies for safe object duplication. It requires only the `PatternKit.Generators` package at compile time—no runtime dependency on PatternKit. + +> Modes: **Shallow with Warnings** (safe-by-default with diagnostics), **Shallow** (explicit shallow cloning), and **DeepWhenPossible** (attempts deep cloning for known types). + +## Quickstart: Class Cloning + +```csharp +using PatternKit.Generators.Prototype; + +[Prototype] +public partial class Person +{ + public string Name { get; set; } = ""; + public int Age { get; set; } + public List Hobbies { get; set; } = new(); +} +``` + +Generated shape (essentials): + +- `Person Clone()` — creates a shallow clone of the instance +- Warnings for mutable reference types (e.g., `List` copied by reference) +- Works with classes, structs, record classes, and record structs + +Usage: + +```csharp +var original = new Person { Name = "Alice", Age = 30 }; +var clone = original.Clone(); + +clone.Name = "Bob"; +Console.WriteLine(original.Name); // Still "Alice" +``` + +## Quickstart: Record Cloning + +```csharp +using PatternKit.Generators.Prototype; + +[Prototype] +public partial record class Person(string Name, int Age); +``` + +Generated shape: + +- `Person Duplicate()` — for records, default method name is "Duplicate" to avoid conflicts with compiler-generated `Clone()` +- Uses record `with` expressions for efficient cloning +- Override method name with `CloneMethodName` if needed + +Usage: + +```csharp +var original = new Person("Alice", 30); +var clone = original.Duplicate(); + +Console.WriteLine(clone.Name); // "Alice" +Console.WriteLine(ReferenceEquals(original, clone)); // False +``` + +## Basic Usage Examples + +### Class with Value Types + +```csharp +[Prototype] +public partial class Vector +{ + public double X { get; set; } + public double Y { get; set; } + public double Z { get; set; } +} + +var v1 = new Vector { X = 1, Y = 2, Z = 3 }; +var v2 = v1.Clone(); // Deep copy - all value types +``` + +### Struct + +```csharp +[Prototype] +public partial struct Point +{ + public int X { get; set; } + public int Y { get; set; } +} + +var p1 = new Point { X = 10, Y = 20 }; +var p2 = p1.Clone(); +``` + +### Record Class + +```csharp +[Prototype] +public partial record class Customer(string Id, string Name, int Credits); + +var c1 = new Customer("C001", "Alice", 100); +var c2 = c1.Duplicate(); // Uses record with-expression +``` + +### Record Struct + +```csharp +[Prototype] +public partial record struct Temperature(double Celsius) +{ + public double Fahrenheit => Celsius * 9.0 / 5.0 + 32.0; +} + +var t1 = new Temperature(25.0); +var t2 = t1.Duplicate(); +``` + +## Configuration Options + +### PrototypeMode + +Controls default cloning behavior for reference types: + +```csharp +// ShallowWithWarnings (default) - warns about mutable reference types +[Prototype(Mode = PrototypeMode.ShallowWithWarnings)] +public partial class Container +{ + public List Items { get; set; } = new(); // WARNING: PKPRO003 +} + +// Shallow - no warnings, explicit shallow cloning +[Prototype(Mode = PrototypeMode.Shallow)] +public partial class Container +{ + public List Items { get; set; } = new(); // No warning +} + +// DeepWhenPossible - attempts deep cloning for known types +[Prototype(Mode = PrototypeMode.DeepWhenPossible)] +public partial class Container +{ + public List Items { get; set; } = new(); // Creates new List(original) +} +``` + +### CloneMethodName + +Customize the generated method name: + +```csharp +[Prototype(CloneMethodName = "Copy")] +public partial class Document +{ + public string Title { get; set; } = ""; +} + +var doc = new Document { Title = "Report" }; +var copy = doc.Copy(); // Not Clone() +``` + +For records, the default is automatically "Duplicate" to avoid conflicts with compiler-generated `Clone()`: + +```csharp +[Prototype] // CloneMethodName defaults to "Duplicate" for records +public partial record class Item(string Name); + +[Prototype(CloneMethodName = "MakeCopy")] // Override if desired +public partial record class Item2(string Name); +``` + +### IncludeExplicit + +Control member selection mode: + +```csharp +// Default: IncludeAll mode - all members cloned unless marked [PrototypeIgnore] +[Prototype] +public partial class User +{ + public string Username { get; set; } = ""; + + [PrototypeIgnore] // Excluded from clone + public string Password { get; set; } = ""; +} + +// ExplicitOnly mode - only members marked [PrototypeInclude] are cloned +[Prototype(IncludeExplicit = true)] +public partial class Config +{ + [PrototypeInclude] // Included in clone + public string ApiKey { get; set; } = ""; + + public string InternalState { get; set; } = ""; // Not cloned +} +``` + +## Per-Member Cloning Strategies + +Override the default strategy for specific members using `[PrototypeStrategy]`: + +### ByReference + +Copy the reference as-is (shallow copy): + +```csharp +[Prototype] +public partial class Logger +{ + [PrototypeStrategy(PrototypeCloneStrategy.ByReference)] + public ILogSink Sink { get; set; } = null!; +} +``` + +**When to use:** +- Immutable types (strings, records with readonly members) +- Shared services (loggers, database connections) +- Flyweight instances + +**Warning:** For mutable types, changes affect both original and clone. + +### ShallowCopy + +Create a new collection with the same element references: + +```csharp +[Prototype] +public partial class Playlist +{ + [PrototypeStrategy(PrototypeCloneStrategy.ShallowCopy)] + public List Songs { get; set; } = new(); +} + +// Generated: +// Songs = new List(original.Songs) +``` + +**When to use:** +- Collections where you want independent collection instances +- Element references can be shared (immutable elements) + +**Limitation:** Elements themselves are not cloned. + +### Clone + +Use a known clone mechanism (ICloneable, Clone() method, copy constructor, or collection copy constructor): + +```csharp +public class Address +{ + public string Street { get; set; } = ""; + public string City { get; set; } = ""; + + public Address Clone() => new() { Street = Street, City = City }; +} + +[Prototype] +public partial class Person +{ + public string Name { get; set; } = ""; + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public Address HomeAddress { get; set; } = new(); + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List PhoneNumbers { get; set; } = new(); +} + +// Generated: +// HomeAddress = original.HomeAddress.Clone() +// PhoneNumbers = new List(original.PhoneNumbers) +``` + +**When to use:** +- Types with explicit Clone() methods +- Types implementing ICloneable +- Collections (List, Dictionary, HashSet, etc.) + +**Generator checks:** +- For custom types: must have `Clone()` method or `ICloneable` +- For collections: uses copy constructor `new List(original)` +- Emits **PKPRO004** error if no suitable mechanism found + +### DeepCopy + +**Status:** Not yet implemented (PKPRO999) + +Planned for recursive deep cloning of complex object graphs. + +### Custom + +Provide your own cloning logic via partial method: + +```csharp +[Prototype] +public partial class GameEntity +{ + [PrototypeStrategy(PrototypeCloneStrategy.Custom)] + public EntityStats Stats { get; set; } = new(); + + // Partial method hook - implement in your code + private static partial EntityStats CloneStats(EntityStats value); +} + +// In your partial class implementation: +public partial class GameEntity +{ + private static partial EntityStats CloneStats(EntityStats value) + { + return new EntityStats + { + Health = value.Health, + Mana = value.Mana, + // Custom logic here + }; + } +} +``` + +**When to use:** +- Complex cloning logic not expressible with other strategies +- Integration with existing clone systems +- Performance-critical custom implementations + +**Generator checks:** +- Emits **PKPRO005** error if partial method `private static partial TMember Clone{MemberName}(TMember value)` not found + +## Member Selection + +### Default: IncludeAll Mode + +All eligible members are cloned unless marked `[PrototypeIgnore]`: + +```csharp +[Prototype] +public partial class Session +{ + public string SessionId { get; set; } = ""; // Cloned + public DateTime CreatedAt { get; set; } // Cloned + + [PrototypeIgnore] + public DateTime LastAccess { get; set; } // NOT cloned + + [PrototypeIgnore] + public int AccessCount { get; set; } // NOT cloned +} +``` + +### ExplicitOnly Mode + +Only members marked `[PrototypeInclude]` are cloned: + +```csharp +[Prototype(IncludeExplicit = true)] +public partial class SecureConfig +{ + [PrototypeInclude] + public string ApiEndpoint { get; set; } = ""; // Cloned + + [PrototypeInclude] + public int Timeout { get; set; } // Cloned + + public string ApiSecret { get; set; } = ""; // NOT cloned + public byte[] EncryptionKey { get; set; } = Array.Empty(); // NOT cloned +} +``` + +**When to use ExplicitOnly:** +- Security-sensitive types (secrets, keys) +- Large types where most members shouldn't be cloned +- Explicit opt-in clarity + +## Clone Construction Strategies + +The generator uses different strategies based on the type: + +### Record with-expression (Highest Priority) + +For record classes and record structs: + +```csharp +[Prototype] +public partial record class Person(string Name, int Age); + +// Generated: +public Person Duplicate() +{ + return this with { }; +} +``` + +**Advantages:** +- Most efficient for records +- Preserves record semantics +- Compiler-optimized + +### Copy Constructor + +If a constructor accepting the same type exists: + +```csharp +[Prototype] +public partial class Point +{ + public int X { get; set; } + public int Y { get; set; } + + public Point() { } + public Point(Point other) // Copy constructor detected + { + X = other.X; + Y = other.Y; + } +} + +// Generated: +public Point Clone() +{ + return new Point(this); +} +``` + +### Parameterless Constructor + Assignment (Fallback) + +When no copy constructor exists: + +```csharp +[Prototype] +public partial class Person +{ + public string Name { get; set; } = ""; + public int Age { get; set; } +} + +// Generated: +public Person Clone() +{ + var clone = new Person(); + clone.Name = this.Name; + clone.Age = this.Age; + return clone; +} +``` + +**Diagnostic:** +- Emits **PKPRO002** error if no construction path is available (no parameterless constructor, no copy constructor, not a record) + +## Diagnostics Reference + +### PKPRO001: Type Not Partial + +**Severity:** Error + +Type marked with `[Prototype]` must be declared as `partial`. + +```csharp +// ❌ Error +[Prototype] +public class Person { } + +// ✅ Fixed +[Prototype] +public partial class Person { } +``` + +### PKPRO002: No Construction Path + +**Severity:** Error + +Cannot construct clone - no supported construction mechanism found. + +```csharp +// ❌ Error: No parameterless constructor +[Prototype] +public partial class Person +{ + public Person(string name) { } // Only parameterized constructor +} + +// ✅ Fixed: Add parameterless constructor +[Prototype] +public partial class Person +{ + public Person() { } + public Person(string name) { } +} +``` + +### PKPRO003: Unsafe Reference Capture + +**Severity:** Warning + +Member is a mutable reference type copied by reference - mutations affect both original and clone. + +```csharp +// ⚠️ Warning +[Prototype(Mode = PrototypeMode.ShallowWithWarnings)] +public partial class Container +{ + public List Items { get; set; } = new(); // PKPRO003 +} + +// ✅ Fixed: Use Clone strategy +[Prototype(Mode = PrototypeMode.ShallowWithWarnings)] +public partial class Container +{ + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Items { get; set; } = new(); +} + +// ✅ Alternative: Use Shallow mode (acknowledges shallow cloning) +[Prototype(Mode = PrototypeMode.Shallow)] +public partial class Container +{ + public List Items { get; set; } = new(); // No warning +} +``` + +### PKPRO004: Clone Mechanism Missing + +**Severity:** Error + +Member has `[PrototypeStrategy(Clone)]` but no suitable clone mechanism is available. + +```csharp +public class CustomType +{ + public int Value { get; set; } + // No Clone() method, no ICloneable +} + +// ❌ Error +[Prototype] +public partial class Container +{ + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public CustomType Data { get; set; } = new(); // PKPRO004 +} + +// ✅ Fixed: Add Clone() method to CustomType +public class CustomType +{ + public int Value { get; set; } + public CustomType Clone() => new() { Value = Value }; +} +``` + +### PKPRO005: Custom Strategy Missing Hook + +**Severity:** Error + +Member has `[PrototypeStrategy(Custom)]` but no partial clone hook found. + +```csharp +// ❌ Error +[Prototype] +public partial class Entity +{ + [PrototypeStrategy(PrototypeCloneStrategy.Custom)] + public EntityData Data { get; set; } = new(); // PKPRO005 +} + +// ✅ Fixed: Add partial method +[Prototype] +public partial class Entity +{ + [PrototypeStrategy(PrototypeCloneStrategy.Custom)] + public EntityData Data { get; set; } = new(); + + private static partial EntityData CloneData(EntityData value); +} + +// Implement in another partial file: +public partial class Entity +{ + private static partial EntityData CloneData(EntityData value) + { + return new EntityData { /* custom logic */ }; + } +} +``` + +### PKPRO006: Attribute Misuse + +**Severity:** Warning + +`[PrototypeInclude]` or `[PrototypeIgnore]` used incorrectly. + +```csharp +// ⚠️ Warning: PrototypeInclude in IncludeAll mode (default) +[Prototype] // IncludeExplicit = false by default +public partial class Config +{ + [PrototypeInclude] // PKPRO006: ignored in this mode + public string Name { get; set; } = ""; +} + +// ✅ Fixed: Use IncludeExplicit = true +[Prototype(IncludeExplicit = true)] +public partial class Config +{ + [PrototypeInclude] + public string Name { get; set; } = ""; +} +``` + +### PKPRO999: DeepCopy Not Implemented + +**Severity:** Error + +The `DeepCopy` strategy is not yet implemented. + +```csharp +// ❌ Error +[Prototype] +public partial class Container +{ + [PrototypeStrategy(PrototypeCloneStrategy.DeepCopy)] + public ComplexObject Data { get; set; } = new(); // PKPRO999 +} + +// ✅ Workaround: Use Clone or Custom +[Prototype] +public partial class Container +{ + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public ComplexObject Data { get; set; } = new(); +} +``` + +## Best Practices and Tips + +### 1. Start with ShallowWithWarnings Mode + +The default mode is safe-by-default and alerts you to potential issues: + +```csharp +[Prototype] // Default: ShallowWithWarnings +public partial class MyClass +{ + public List Items { get; set; } = new(); // You'll get a warning +} +``` + +Address warnings by choosing the appropriate strategy for each mutable reference type. + +### 2. Use ByReference for Shared Services + +```csharp +[Prototype] +public partial class RequestHandler +{ + [PrototypeStrategy(PrototypeCloneStrategy.ByReference)] + public ILogger Logger { get; set; } = null!; // Shared, don't clone + + [PrototypeStrategy(PrototypeCloneStrategy.ByReference)] + public IDatabase Database { get; set; } = null!; // Shared, don't clone +} +``` + +### 3. Clone Collections When Elements Are Immutable + +```csharp +[Prototype] +public partial class Report +{ + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Tags { get; set; } = new(); // Strings are immutable + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public Dictionary Metrics { get; set; } = new(); +} +``` + +### 4. Use Custom Strategy for Complex Scenarios + +```csharp +[Prototype] +public partial class GameState +{ + [PrototypeStrategy(PrototypeCloneStrategy.Custom)] + public EntityRegistry Entities { get; set; } = new(); + + private static partial EntityRegistry CloneEntities(EntityRegistry value) + { + var clone = new EntityRegistry(); + foreach (var entity in value.GetAll()) + { + // Deep clone each entity with custom logic + clone.Add(entity.DeepClone()); + } + return clone; + } +} +``` + +### 5. Ignore Transient/Computed Members + +```csharp +[Prototype] +public partial class CachedData +{ + public byte[] Data { get; set; } = Array.Empty(); + + [PrototypeIgnore] // Don't clone cache + public DateTime LastAccess { get; set; } + + [PrototypeIgnore] // Don't clone derived value + public int CachedSize => Data.Length; +} +``` + +### 6. Use ExplicitOnly for Security-Sensitive Types + +```csharp +[Prototype(IncludeExplicit = true)] +public partial class Credentials +{ + [PrototypeInclude] + public string Username { get; set; } = ""; + + // Secrets NOT cloned by default + public string Password { get; set; } = ""; + public string ApiKey { get; set; } = ""; +} +``` + +### 7. Prefer Records for Immutable Data + +Records get efficient cloning via `with` expressions: + +```csharp +[Prototype] +public partial record class User(string Id, string Name, DateTime Created); + +// Efficient: uses record with-expression +var clone = user.Duplicate(); +``` + +### 8. Test Clone Independence + +Always verify clones are truly independent: + +```csharp +[Prototype] +public partial class Document +{ + public string Title { get; set; } = ""; + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Tags { get; set; } = new(); +} + +var original = new Document { Title = "Report", Tags = ["draft"] }; +var clone = original.Clone(); + +// Verify independence +clone.Title = "Copy"; +clone.Tags.Add("final"); + +Assert.Equal("Report", original.Title); // Original unchanged +Assert.Single(original.Tags); // Original list unchanged +``` + +## Real-World Scenarios + +### Scenario 1: Configuration Snapshots + +```csharp +[Prototype] +public partial class AppConfig +{ + public string Environment { get; set; } = "development"; + public int MaxConnections { get; set; } = 10; + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public Dictionary Settings { get; set; } = new(); +} + +// Take snapshot before changes +var originalConfig = GetCurrentConfig(); +var snapshot = originalConfig.Clone(); + +try +{ + ApplyNewSettings(originalConfig); + ValidateConfig(originalConfig); +} +catch +{ + // Restore from snapshot + RestoreConfig(snapshot); +} +``` + +### Scenario 2: Document Versioning + +```csharp +[Prototype] +public partial class Document +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public int Version { get; set; } = 1; + + [PrototypeIgnore] // Don't copy timestamps + public DateTime Created { get; set; } +} + +public Document CreateNewVersion(Document current) +{ + var newVersion = current.Clone(); + newVersion.Id = Guid.NewGuid().ToString(); // New ID + newVersion.Version++; // Increment version + return newVersion; +} +``` + +### Scenario 3: Game Entity Spawning + +```csharp +[Prototype] +public partial class Enemy +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public int Health { get; set; } + public int Damage { get; set; } + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Abilities { get; set; } = new(); + + [PrototypeStrategy(PrototypeCloneStrategy.ByReference)] + public IEnemyAI AI { get; set; } = null!; // Shared behavior +} + +// Prototype registry +var goblinPrototype = new Enemy +{ + Type = "Goblin", + Health = 50, + Abilities = ["Scratch", "Flee"] +}; + +// Spawn many enemies efficiently +for (int i = 0; i < 100; i++) +{ + var enemy = goblinPrototype.Clone(); + enemy.Id = $"goblin-{i}"; + SpawnInWorld(enemy); +} +``` + +### Scenario 4: Test Data Builders + +```csharp +[Prototype] +public partial class TestUser +{ + public string Username { get; set; } = "test-user"; + public string Email { get; set; } = "test@example.com"; + public bool IsActive { get; set; } = true; + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Roles { get; set; } = new() { "user" }; +} + +// Base test fixtures +var baseUser = new TestUser(); +var adminUser = baseUser.Clone(); +adminUser.Roles.Add("admin"); + +var inactiveUser = baseUser.Clone(); +inactiveUser.IsActive = false; +``` + +### Scenario 5: Form State Management + +```csharp +[Prototype] +public partial class FormState +{ + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public Dictionary Values { get; set; } = new(); + + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public Dictionary Errors { get; set; } = new(); + + public bool IsDirty { get; set; } +} + +// Undo/Redo implementation +var history = new Stack(); + +void SaveState(FormState current) +{ + history.Push(current.Clone()); +} + +FormState Undo() +{ + return history.Count > 0 ? history.Pop() : null; +} +``` + +## Performance Considerations + +### Cloning is Fast for Value Types + +```csharp +[Prototype] +public partial struct Point3D +{ + public double X { get; set; } + public double Y { get; set; } + public double Z { get; set; } +} + +// Very fast - just copies bytes +var clone = point.Clone(); +``` + +### Record with-expression is Optimized + +```csharp +[Prototype] +public partial record class Customer(string Id, string Name); + +// Compiler-optimized shallow copy +var clone = customer.Duplicate(); +``` + +### Collection Cloning Has Overhead + +```csharp +[Prototype] +public partial class Container +{ + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Items { get; set; } = new(); // O(n) copy +} + +// Consider ByReference for read-only scenarios or immutable collections +``` + +### Custom Strategy for Optimization + +```csharp +[Prototype] +public partial class LargeObject +{ + [PrototypeStrategy(PrototypeCloneStrategy.Custom)] + public byte[] Data { get; set; } = Array.Empty(); + + private static partial byte[] CloneData(byte[] value) + { + if (value.Length == 0) return Array.Empty(); + + var clone = new byte[value.Length]; + Buffer.BlockCopy(value, 0, clone, 0, value.Length); // Fast copy + return clone; + } +} +``` + +## See Also + +- [Prototype Demo Example](../examples/prototype-demo.md) - Real-world usage with game entities +- [PatternKit.Creational.Prototype](../../src/PatternKit/Creational/Prototype/) - Runtime prototype registry +- [Builder Generator](builder.md) - For fluent object construction From 4293f216974135c30519d085aef6bc2148fecb0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:15:26 +0000 Subject: [PATCH 07/14] docs: Fix path reference to PatternKit.Core in Prototype documentation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/examples/prototype-demo.md | 2 +- docs/generators/prototype.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/prototype-demo.md b/docs/examples/prototype-demo.md index a9fe03b..473d5c9 100644 --- a/docs/examples/prototype-demo.md +++ b/docs/examples/prototype-demo.md @@ -840,5 +840,5 @@ dotnet run --project src/PatternKit.Examples --framework net9.0 ## Further Reading - [Prototype Generator](../generators/prototype.md) - Automated clone method generation -- [PatternKit.Creational.Prototype](../../src/PatternKit/Creational/Prototype/) - Runtime prototype API +- [PatternKit.Core.Creational.Prototype](../../src/PatternKit.Core/Creational/Prototype/) - Runtime prototype API - [Gang of Four Design Patterns](https://en.wikipedia.org/wiki/Design_Patterns) - Original pattern catalog diff --git a/docs/generators/prototype.md b/docs/generators/prototype.md index 9242ccb..773875c 100644 --- a/docs/generators/prototype.md +++ b/docs/generators/prototype.md @@ -965,5 +965,5 @@ public partial class LargeObject ## See Also - [Prototype Demo Example](../examples/prototype-demo.md) - Real-world usage with game entities -- [PatternKit.Creational.Prototype](../../src/PatternKit/Creational/Prototype/) - Runtime prototype registry +- [PatternKit.Core.Creational.Prototype](../../src/PatternKit.Core/Creational/Prototype/) - Runtime prototype registry - [Builder Generator](builder.md) - For fluent object construction From ea559e29675db300ad73866e855fa053d7138ce5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:16:28 +0000 Subject: [PATCH 08/14] Add Prototype pattern generator with complete implementation, tests, and documentation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/examples/toc.yml | 4 ++++ docs/generators/toc.yml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 20aa962..900aba6 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -42,6 +42,10 @@ - name: Patterns Showcase — Integrated Order Processing href: patterns-showcase.md + +- name: Prototype — Game Character Factory with Clone Registry + href: prototype-demo.md + - name: Proxy Pattern Demonstrations — Virtual, Protection, Caching, Logging, Mocking, Remote href: proxy-demo.md diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 8885c33..50f2159 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -10,6 +10,9 @@ - name: Builder href: builder.md +- name: Prototype + href: prototype.md + - name: Proxy href: proxy.md From 8a547200f0600e59d6c716ad736596a04a47221b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:20:43 +0000 Subject: [PATCH 09/14] Address code review feedback: improve diagnostics numbering, collection checking, and custom method validation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../AnalyzerReleases.Unshipped.md | 2 +- .../PrototypeGenerator.cs | 40 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index b53c207..364a77c 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -58,5 +58,5 @@ PKPRO003 | PatternKit.Generators.Prototype | Warning | Unsafe reference capture PKPRO004 | PatternKit.Generators.Prototype | Error | Requested Clone strategy but no clone mechanism found PKPRO005 | PatternKit.Generators.Prototype | Error | Custom strategy requires partial clone hook, but none found PKPRO006 | PatternKit.Generators.Prototype | Warning | Include/Ignore attribute misuse -PKPRO999 | PatternKit.Generators.Prototype | Error | DeepCopy strategy not yet implemented +PKPRO007 | PatternKit.Generators.Prototype | Error | DeepCopy strategy not yet implemented diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index 7a3f7e5..9e50056 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -21,7 +21,7 @@ public sealed class PrototypeGenerator : IIncrementalGenerator private const string DiagIdCloneMechanismMissing = "PKPRO004"; private const string DiagIdCustomStrategyMissing = "PKPRO005"; private const string DiagIdAttributeMisuse = "PKPRO006"; - private const string DiagIdDeepCopyNotImplemented = "PKPRO999"; + private const string DiagIdDeepCopyNotImplemented = "PKPRO007"; private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( id: DiagIdTypeNotPartial, @@ -396,12 +396,15 @@ private List GetMembersForClone( } else if (strategy == CloneStrategy.Custom) { - // Check for custom partial method + // Check for custom partial method with correct signature var containingType = member.ContainingType; var methodName = $"Clone{member.Name}"; var hasCustomMethod = containingType.GetMembers(methodName) .OfType() - .Any(m => m.IsStatic && m.IsPartialDefinition && m.Parameters.Length == 1); + .Any(m => m.IsStatic && + m.IsPartialDefinition && + m.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type, memberType)); if (!hasCustomMethod) { @@ -521,22 +524,25 @@ private bool ImplementsICloneable(ITypeSymbol type) i.ToDisplayString() == "System.ICloneable"); } + private static readonly HashSet CollectionsWithCopyConstructor = new HashSet + { + "System.Collections.Generic.List", + "System.Collections.Generic.HashSet", + "System.Collections.Generic.Queue", + "System.Collections.Generic.Stack", + "System.Collections.Generic.LinkedList", + "System.Collections.Generic.Dictionary", + "System.Collections.Generic.SortedSet", + "System.Collections.Generic.SortedDictionary" + }; + private bool IsCollectionWithCopyConstructor(ITypeSymbol type) { if (type is not INamedTypeSymbol namedType) return false; var typeName = namedType.ConstructedFrom.ToDisplayString(); - - // List, HashSet, Queue, Stack, etc. - return typeName == "System.Collections.Generic.List" || - typeName == "System.Collections.Generic.HashSet" || - typeName == "System.Collections.Generic.Queue" || - typeName == "System.Collections.Generic.Stack" || - typeName == "System.Collections.Generic.LinkedList" || - typeName == "System.Collections.Generic.Dictionary" || - typeName == "System.Collections.Generic.SortedSet" || - typeName == "System.Collections.Generic.SortedDictionary"; + return CollectionsWithCopyConstructor.Contains(typeName); } private static bool HasAttribute(ISymbol symbol, string attributeName) @@ -716,13 +722,9 @@ private void GenerateRecordWithConstruction(StringBuilder sb, TypeInfo typeInfo, var assignments = typeInfo.Members .Where(m => !m.IsReadOnly) // Can't set readonly members in with-expression - .Select(m => $"{m.Name} = {cloneExprs[m.Name]}") - .ToList(); + .Select(m => $"{m.Name} = {cloneExprs[m.Name]}"); - if (assignments.Count > 0) - { - sb.Append(string.Join(", ", assignments)); - } + sb.Append(string.Join(", ", assignments)); sb.AppendLine(" };"); } From 009929613f0f9045fcb7528b66502bb0f44b215d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:38:13 +0000 Subject: [PATCH 10/14] Address PR review comments: fix docs, simplify code, add comprehensive tests for all diagnostics and strategies Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/prototype.md | 14 +- .../PrototypeGenerator.cs | 17 +- .../PrototypeGeneratorTests.cs | 341 ++++++++++++++++++ 3 files changed, 350 insertions(+), 22 deletions(-) diff --git a/docs/generators/prototype.md b/docs/generators/prototype.md index 773875c..f5dd7ec 100644 --- a/docs/generators/prototype.md +++ b/docs/generators/prototype.md @@ -281,13 +281,13 @@ public partial class Person ### DeepCopy -**Status:** Not yet implemented (PKPRO999) +**Status:** Not yet implemented – selecting this strategy will emit diagnostic **PKPRO007** ("DeepCopy strategy not yet implemented"). Planned for recursive deep cloning of complex object graphs. ### Custom -Provide your own cloning logic via partial method: +Provide your own cloning logic via partial method. The generator will emit a partial method declaration; you only need to provide the implementation: ```csharp [Prototype] @@ -295,12 +295,10 @@ public partial class GameEntity { [PrototypeStrategy(PrototypeCloneStrategy.Custom)] public EntityStats Stats { get; set; } = new(); - - // Partial method hook - implement in your code - private static partial EntityStats CloneStats(EntityStats value); } -// In your partial class implementation: +// The generator emits the declaration automatically. +// You only need to provide the implementation in your partial class: public partial class GameEntity { private static partial EntityStats CloneStats(EntityStats value) @@ -602,7 +600,7 @@ public partial class Config } ``` -### PKPRO999: DeepCopy Not Implemented +### PKPRO007: DeepCopy Not Implemented **Severity:** Error @@ -614,7 +612,7 @@ The `DeepCopy` strategy is not yet implemented. public partial class Container { [PrototypeStrategy(PrototypeCloneStrategy.DeepCopy)] - public ComplexObject Data { get; set; } = new(); // PKPRO999 + public ComplexObject Data { get; set; } = new(); // PKPRO007 } // ✅ Workaround: Use Clone or Custom diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index 9e50056..1eaaf0d 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -225,15 +225,7 @@ private ConstructionStrategy DetermineConstructionStrategy(INamedTypeSymbol type if (typeInfo.IsRecordClass || typeInfo.IsRecordStruct) { // Check if all members are init-only or readonly - bool allInit = true; - foreach (var member in typeInfo.Members) - { - if (!member.IsInitOnly && !member.IsReadOnly) - { - allInit = false; - break; - } - } + bool allInit = typeInfo.Members.All(member => member.IsInitOnly || member.IsReadOnly); if (allInit) return ConstructionStrategy.RecordWith; @@ -505,11 +497,8 @@ private bool HasCloneMechanism(ITypeSymbol type) return true; // Check for copy constructor - if (type is INamedTypeSymbol namedType) - { - if (HasCopyConstructor(namedType)) - return true; - } + if (type is INamedTypeSymbol namedType && HasCopyConstructor(namedType)) + return true; // Check for List or similar collections with copy constructors if (IsCollectionWithCopyConstructor(type)) diff --git a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs index fca1083..a3b5daa 100644 --- a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs @@ -303,4 +303,345 @@ public partial class Container var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); Assert.Contains(diagnostics, d => d.Id == "PKPRO003" && d.Severity == DiagnosticSeverity.Warning); } + + [Fact] + public void ErrorOnCloneStrategyWithoutMechanism() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class NonCloneable + { + public string Value { get; set; } = ""; + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public NonCloneable Data { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnCloneStrategyWithoutMechanism)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO004 error for missing clone mechanism + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO004" && d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void ErrorOnCustomStrategyWithoutPartialMethod() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class CustomData + { + public string Value { get; set; } = ""; + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Custom)] + public CustomData Data { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnCustomStrategyWithoutPartialMethod)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO005 error for missing custom hook + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO005" && d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void WarnOnAttributeMisuseIncludeInIncludeAllMode() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class Container + { + [PrototypeInclude] + public string Value { get; set; } = ""; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(WarnOnAttributeMisuseIncludeInIncludeAllMode)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO006 warning for attribute misuse + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO006" && d.Severity == DiagnosticSeverity.Warning); + } + + [Fact] + public void WarnOnAttributeMisuseIgnoreInExplicitMode() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype(IncludeExplicit = true)] + public partial class Container + { + [PrototypeIgnore] + public string Value { get; set; } = ""; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(WarnOnAttributeMisuseIgnoreInExplicitMode)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO006 warning for attribute misuse + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO006" && d.Severity == DiagnosticSeverity.Warning); + } + + [Fact] + public void ErrorOnDeepCopyStrategy() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class ComplexData + { + public string Value { get; set; } = ""; + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.DeepCopy)] + public ComplexData Data { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnDeepCopyStrategy)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO007 error for DeepCopy not implemented + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO007" && d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void GenerateCloneWithShallowCopyStrategy() + { + const string source = """ + using PatternKit.Generators.Prototype; + using System.Collections.Generic; + + namespace TestNamespace; + + [Prototype(Mode = PrototypeMode.Shallow)] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.ShallowCopy)] + public List Items { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithShallowCopyStrategy)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No errors + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that new List is created (using fully qualified name) + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Container.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("new global::System.Collections.Generic.List(this.Items)", generatedSource); + } + + [Fact] + public void GenerateCloneWithCloneStrategyUsingICloneable() + { + const string source = """ + using PatternKit.Generators.Prototype; + using System; + + namespace TestNamespace; + + public class CloneableData : ICloneable + { + public string Value { get; set; } = ""; + public object Clone() => new CloneableData { Value = this.Value }; + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public CloneableData Data { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithCloneStrategyUsingICloneable)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No errors + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that Clone() is called + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Container.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Clone()", generatedSource); + } + + [Fact] + public void GenerateCloneWithCloneStrategyUsingCloneMethod() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class DataWithClone + { + public string Value { get; set; } = ""; + public DataWithClone Clone() => new DataWithClone { Value = this.Value }; + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public DataWithClone Data { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithCloneStrategyUsingCloneMethod)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No errors + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that Clone() is called + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Container.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Clone()", generatedSource); + } + + [Fact] + public void GenerateCloneWithCloneStrategyUsingCopyConstructor() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class DataWithCopyCtor + { + public string Value { get; set; } = ""; + + public DataWithCopyCtor() { } + public DataWithCopyCtor(DataWithCopyCtor other) + { + Value = other.Value; + } + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public DataWithCopyCtor Data { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithCloneStrategyUsingCopyConstructor)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No errors + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that copy constructor is called + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Container.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("new global::TestNamespace.DataWithCopyCtor(this.Data)", generatedSource); + } + + [Fact] + public void GenerateCloneWithCloneStrategyForListCollection() + { + const string source = """ + using PatternKit.Generators.Prototype; + using System.Collections.Generic; + + namespace TestNamespace; + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public List Items { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(GenerateCloneWithCloneStrategyForListCollection)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // No errors + Assert.All(result.Results, r => Assert.Empty(r.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + + // Compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Check that List copy constructor is used (using fully qualified name) + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Container.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("new global::System.Collections.Generic.List(this.Items)", generatedSource); + } } From 7d7314d86f0ac5bba06af026068f08db460571be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:06:49 +0000 Subject: [PATCH 11/14] Address PR review: add generic/nested type diagnostics, fix static Clone() detection, remove duplicate partial methods, add PKPRO002 test Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/prototype.md | 10 ++- .../AnalyzerReleases.Unshipped.md | 2 + .../PrototypeGenerator.cs | 64 ++++++++++----- .../PrototypeGeneratorTests.cs | 80 +++++++++++++++++++ 4 files changed, 131 insertions(+), 25 deletions(-) diff --git a/docs/generators/prototype.md b/docs/generators/prototype.md index f5dd7ec..891d12c 100644 --- a/docs/generators/prototype.md +++ b/docs/generators/prototype.md @@ -287,7 +287,7 @@ Planned for recursive deep cloning of complex object graphs. ### Custom -Provide your own cloning logic via partial method. The generator will emit a partial method declaration; you only need to provide the implementation: +Provide your own cloning logic via partial method. You must declare and implement the partial method yourself: ```csharp [Prototype] @@ -295,10 +295,12 @@ public partial class GameEntity { [PrototypeStrategy(PrototypeCloneStrategy.Custom)] public EntityStats Stats { get; set; } = new(); + + // Declare the partial method + private static partial EntityStats CloneStats(EntityStats value); } -// The generator emits the declaration automatically. -// You only need to provide the implementation in your partial class: +// Provide the implementation in your partial class: public partial class GameEntity { private static partial EntityStats CloneStats(EntityStats value) @@ -319,7 +321,7 @@ public partial class GameEntity - Performance-critical custom implementations **Generator checks:** -- Emits **PKPRO005** error if partial method `private static partial TMember Clone{MemberName}(TMember value)` not found +- Emits **PKPRO005** error if partial method `private static partial TMember Clone{MemberName}(TMember value)` not declared and implemented ## Member Selection diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 364a77c..635c0be 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -59,4 +59,6 @@ PKPRO004 | PatternKit.Generators.Prototype | Error | Requested Clone strategy bu PKPRO005 | PatternKit.Generators.Prototype | Error | Custom strategy requires partial clone hook, but none found PKPRO006 | PatternKit.Generators.Prototype | Warning | Include/Ignore attribute misuse PKPRO007 | PatternKit.Generators.Prototype | Error | DeepCopy strategy not yet implemented +PKPRO008 | PatternKit.Generators.Prototype | Error | Generic types not supported for Prototype pattern +PKPRO009 | PatternKit.Generators.Prototype | Error | Nested types not supported for Prototype pattern diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index 1eaaf0d..b344bbe 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -22,6 +22,8 @@ public sealed class PrototypeGenerator : IIncrementalGenerator private const string DiagIdCustomStrategyMissing = "PKPRO005"; private const string DiagIdAttributeMisuse = "PKPRO006"; private const string DiagIdDeepCopyNotImplemented = "PKPRO007"; + private const string DiagIdGenericTypeNotSupported = "PKPRO008"; + private const string DiagIdNestedTypeNotSupported = "PKPRO009"; private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( id: DiagIdTypeNotPartial, @@ -79,6 +81,22 @@ public sealed class PrototypeGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor GenericTypeNotSupportedDescriptor = new( + id: DiagIdGenericTypeNotSupported, + title: "Generic types not supported for Prototype pattern", + messageFormat: "Type '{0}' is generic, which is not currently supported by the Prototype generator. Remove the [Prototype] attribute or use a non-generic type.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NestedTypeNotSupportedDescriptor = new( + id: DiagIdNestedTypeNotSupported, + title: "Nested types not supported for Prototype pattern", + messageFormat: "Type '{0}' is nested inside another type, which is not currently supported by the Prototype generator. Remove the [Prototype] attribute or move the type to the top level.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Find all type declarations with [Prototype] attribute @@ -119,6 +137,26 @@ private void GeneratePrototypeForType( return; } + // Check for generic types + if (typeSymbol.IsGenericType) + { + context.ReportDiagnostic(Diagnostic.Create( + GenericTypeNotSupportedDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Check for nested types + if (typeSymbol.ContainingType is not null) + { + context.ReportDiagnostic(Diagnostic.Create( + NestedTypeNotSupportedDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + // Parse attribute arguments var config = ParsePrototypeConfig(attribute); @@ -488,10 +526,11 @@ private bool HasCloneMechanism(ITypeSymbol type) if (ImplementsICloneable(type)) return true; - // Check for Clone() method returning same type + // Check for Clone() method returning same type (instance methods only) var cloneMethod = type.GetMembers("Clone") .OfType() - .FirstOrDefault(m => m.Parameters.Length == 0 && + .FirstOrDefault(m => !m.IsStatic && + m.Parameters.Length == 0 && SymbolEqualityComparer.Default.Equals(m.ReturnType, type)); if (cloneMethod is not null) return true; @@ -574,9 +613,6 @@ private string GenerateCloneMethod(TypeInfo typeInfo, PrototypeConfig config, So sb.AppendLine($"{typeKeyword} {typeInfo.TypeName}"); sb.AppendLine("{"); - // Generate custom clone hook declarations - GenerateCustomCloneHooks(sb, typeInfo); - // Generate Clone method GenerateCloneMethodBody(sb, typeInfo, config, context); @@ -585,20 +621,6 @@ private string GenerateCloneMethod(TypeInfo typeInfo, PrototypeConfig config, So return sb.ToString(); } - private void GenerateCustomCloneHooks(StringBuilder sb, TypeInfo typeInfo) - { - var customMembers = typeInfo.Members.Where(m => m.CloneStrategy == CloneStrategy.Custom).ToList(); - if (customMembers.Count == 0) - return; - - foreach (var member in customMembers) - { - sb.AppendLine($" /// Custom clone hook for {member.Name}."); - sb.AppendLine($" private static partial {member.Type} Clone{member.Name}({member.Type} value);"); - sb.AppendLine(); - } - } - private void GenerateCloneMethodBody(StringBuilder sb, TypeInfo typeInfo, PrototypeConfig config, SourceProductionContext context) { sb.AppendLine($" /// Creates a clone of this instance."); @@ -679,10 +701,10 @@ private string GenerateCloneCallExpression(MemberInfo member) return $"({member.Type})this.{member.Name}.Clone()"; } - // Check for Clone() method + // Check for Clone() method (instance methods only) var cloneMethod = member.TypeSymbol.GetMembers("Clone") .OfType() - .FirstOrDefault(m => m.Parameters.Length == 0); + .FirstOrDefault(m => !m.IsStatic && m.Parameters.Length == 0); if (cloneMethod is not null) { return $"this.{member.Name}.Clone()"; diff --git a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs index a3b5daa..4f78f68 100644 --- a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs @@ -644,4 +644,84 @@ public partial class Container .SourceText.ToString(); Assert.Contains("new global::System.Collections.Generic.List(this.Items)", generatedSource); } + + [Fact] + public void ErrorOnNoConstructionPath() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class Container + { + public string Value { get; } + + public Container(string value) + { + Value = value; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnNoConstructionPath)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO002 error for no construction path + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO002" && d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void ErrorOnGenericType() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class GenericContainer + { + public T Value { get; set; } = default!; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnGenericType)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO008 error for generic type + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO008" && d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void ErrorOnNestedType() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class OuterClass + { + [Prototype] + public partial class InnerClass + { + public string Value { get; set; } = ""; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnNestedType)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO009 error for nested type + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO009" && d.Severity == DiagnosticSeverity.Error); + } } From 46c1ebbfe69bc279e0846dc14a58dc161242ba82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 02:22:30 +0000 Subject: [PATCH 12/14] Address PR review: verify Custom return types, relax constructor accessibility, add static Clone() test, document enum sync Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../PrototypeGenerator.cs | 15 ++-- .../PrototypeGeneratorTests.cs | 70 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index b344bbe..cb35d95 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -301,9 +301,9 @@ private bool HasParameterlessConstructor(INamedTypeSymbol typeSymbol) if (typeSymbol.TypeKind == TypeKind.Struct) return true; - return typeSymbol.Constructors.Any(c => - c.Parameters.Length == 0 && - c.DeclaredAccessibility >= Accessibility.Internal); + // Any parameterless constructor is usable from the generated clone method, + // which is emitted into the same partial type, regardless of accessibility. + return typeSymbol.Constructors.Any(c => c.Parameters.Length == 0); } private List GetMembersForClone( @@ -426,7 +426,7 @@ private List GetMembersForClone( } else if (strategy == CloneStrategy.Custom) { - // Check for custom partial method with correct signature + // Check for custom partial method with correct signature (parameter and return type must match member type) var containingType = member.ContainingType; var methodName = $"Clone{member.Name}"; var hasCustomMethod = containingType.GetMembers(methodName) @@ -434,7 +434,8 @@ private List GetMembersForClone( .Any(m => m.IsStatic && m.IsPartialDefinition && m.Parameters.Length == 1 && - SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type, memberType)); + SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type, memberType) && + SymbolEqualityComparer.Default.Equals(m.ReturnType, memberType)); if (!hasCustomMethod) { @@ -784,6 +785,8 @@ private class PrototypeConfig public bool IncludeExplicit { get; set; } } + // Internal enums that mirror the public attribute enums + // These must stay in sync with PatternKit.Generators.Prototype.PrototypeMode private enum PrototypeMode { ShallowWithWarnings = 0, @@ -799,6 +802,8 @@ private enum ConstructionStrategy ParameterlessConstructor } + // Internal enum that mirrors PatternKit.Generators.Prototype.PrototypeCloneStrategy + // Values must stay in sync with the public enum private enum CloneStrategy { ByReference = 0, diff --git a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs index 4f78f68..50f26e2 100644 --- a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs @@ -724,4 +724,74 @@ public partial class InnerClass var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); Assert.Contains(diagnostics, d => d.Id == "PKPRO009" && d.Severity == DiagnosticSeverity.Error); } + + [Fact] + public void ErrorOnStaticCloneMethod() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class DataWithStaticClone + { + public string Value { get; set; } = ""; + + // Static Clone method - should NOT be treated as valid + public static DataWithStaticClone Clone() + { + return new DataWithStaticClone(); + } + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Clone)] + public DataWithStaticClone Data { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnStaticCloneMethod)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO004 error because static Clone() is not a valid mechanism + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO004" && d.Severity == DiagnosticSeverity.Error); + } + + [Fact] + public void SucceedWithPrivateParameterlessConstructor() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class SecureContainer + { + public string Value { get; set; } = ""; + + private SecureContainer() + { + } + + public static SecureContainer Create() => new SecureContainer(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(SucceedWithPrivateParameterlessConstructor)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should succeed - private parameterless constructor is accessible from generated code + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.DoesNotContain(diagnostics, d => d.Id == "PKPRO002"); + + // Compilation should succeed + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } } From a7c93c5c83182f81bbdf791b54a7cfc69166c2d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 05:03:21 +0000 Subject: [PATCH 13/14] Address PR review: fix init-only property cloning, add abstract class diagnostic, add comprehensive tests Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../AnalyzerReleases.Unshipped.md | 1 + .../PrototypeGenerator.cs | 22 +++- .../PrototypeGeneratorTests.cs | 105 ++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 635c0be..ba46011 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -61,4 +61,5 @@ PKPRO006 | PatternKit.Generators.Prototype | Warning | Include/Ignore attribute PKPRO007 | PatternKit.Generators.Prototype | Error | DeepCopy strategy not yet implemented PKPRO008 | PatternKit.Generators.Prototype | Error | Generic types not supported for Prototype pattern PKPRO009 | PatternKit.Generators.Prototype | Error | Nested types not supported for Prototype pattern +PKPRO010 | PatternKit.Generators.Prototype | Error | Abstract types not supported for Prototype pattern diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index cb35d95..3559ceb 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -24,6 +24,7 @@ public sealed class PrototypeGenerator : IIncrementalGenerator private const string DiagIdDeepCopyNotImplemented = "PKPRO007"; private const string DiagIdGenericTypeNotSupported = "PKPRO008"; private const string DiagIdNestedTypeNotSupported = "PKPRO009"; + private const string DiagIdAbstractTypeNotSupported = "PKPRO010"; private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( id: DiagIdTypeNotPartial, @@ -97,6 +98,14 @@ public sealed class PrototypeGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor AbstractTypeNotSupportedDescriptor = new( + id: DiagIdAbstractTypeNotSupported, + title: "Abstract types not supported for Prototype pattern", + messageFormat: "Type '{0}' is abstract, which is not supported by the Prototype generator. Abstract types cannot be instantiated for cloning.", + category: "PatternKit.Generators.Prototype", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Find all type declarations with [Prototype] attribute @@ -157,6 +166,16 @@ private void GeneratePrototypeForType( return; } + // Check for abstract types (non-record abstract classes cannot be instantiated) + if (typeSymbol.IsAbstract && !typeSymbol.IsRecord) + { + context.ReportDiagnostic(Diagnostic.Create( + AbstractTypeNotSupportedDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + // Parse attribute arguments var config = ParsePrototypeConfig(attribute); @@ -758,7 +777,8 @@ private void GenerateCopyConstructorConstruction(StringBuilder sb, TypeInfo type private void GenerateParameterlessConstructorConstruction(StringBuilder sb, TypeInfo typeInfo, Dictionary cloneExprs) { // Use object initializer syntax if possible - var settableMembers = typeInfo.Members.Where(m => !m.IsReadOnly && !m.IsInitOnly).ToList(); + // Init-only properties can be set in object initializers + var settableMembers = typeInfo.Members.Where(m => !m.IsReadOnly).ToList(); if (settableMembers.Count > 0) { diff --git a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs index 50f26e2..15a96a7 100644 --- a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs @@ -794,4 +794,109 @@ private SecureContainer() var emit = updated.Emit(Stream.Null); Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); } + + [Fact] + public void SucceedWithInitOnlyPropertiesOnClass() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class ImmutableData + { + public string Name { get; init; } = ""; + public int Value { get; init; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(SucceedWithInitOnlyPropertiesOnClass)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should succeed - init-only properties can be set in object initializers + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + // Compilation should succeed + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify init-only properties are cloned + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "ImmutableData.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Name = this.Name", generatedSource); + Assert.Contains("Value = this.Value", generatedSource); + } + + [Fact] + public void SucceedWithInitOnlyPropertiesWithCopyConstructor() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public partial class DataWithCtor + { + public string Name { get; init; } = ""; + public int Value { get; set; } + + public DataWithCtor() { } + + public DataWithCtor(DataWithCtor other) + { + Name = other.Name; + Value = other.Value; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(SucceedWithInitOnlyPropertiesWithCopyConstructor)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should succeed - copy constructor handles init-only properties + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + // Compilation should succeed + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify copy constructor is used + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "DataWithCtor.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("new global::TestNamespace.DataWithCtor(this)", generatedSource); + } + + [Fact] + public void ErrorOnAbstractClass() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + [Prototype] + public abstract partial class AbstractBase + { + public string Value { get; set; } = ""; + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ErrorOnAbstractClass)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out _); + + // Should have PKPRO010 error for abstract type + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKPRO010" && d.Severity == DiagnosticSeverity.Error); + } } From c960f9ad9b478e34ef4723e47197aa4d61685927 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 05:47:47 +0000 Subject: [PATCH 14/14] Address PR review: add missing diagnostic docs, fix message clarity, add Custom/DeepWhenPossible tests Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/prototype.md | 69 ++++++++++++ .../PrototypeGenerator.cs | 2 +- .../PrototypeGeneratorTests.cs | 106 ++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/docs/generators/prototype.md b/docs/generators/prototype.md index 891d12c..fad0d40 100644 --- a/docs/generators/prototype.md +++ b/docs/generators/prototype.md @@ -626,6 +626,75 @@ public partial class Container } ``` +### PKPRO008: Generic Types Not Supported + +**Severity:** Error + +Generic types are not currently supported by the Prototype generator. + +```csharp +// ❌ Error +[Prototype] +public partial class Container // PKPRO008 +{ + public T Value { get; set; } = default!; +} + +// ✅ Workaround: Use non-generic type +[Prototype] +public partial class StringContainer +{ + public string Value { get; set; } = ""; +} +``` + +### PKPRO009: Nested Types Not Supported + +**Severity:** Error + +Nested types (types declared inside other types) are not currently supported. + +```csharp +// ❌ Error +public class OuterClass +{ + [Prototype] + public partial class InnerClass // PKPRO009 + { + public string Value { get; set; } = ""; + } +} + +// ✅ Workaround: Move type to top level +[Prototype] +public partial class InnerClass +{ + public string Value { get; set; } = ""; +} +``` + +### PKPRO010: Abstract Types Not Supported + +**Severity:** Error + +Abstract non-record classes cannot be instantiated for cloning. + +```csharp +// ❌ Error +[Prototype] +public abstract partial class AbstractBase // PKPRO010 +{ + public string Value { get; set; } = ""; +} + +// ✅ Workaround: Use concrete class or record +[Prototype] +public partial class ConcreteClass +{ + public string Value { get; set; } = ""; +} +``` + ## Best Practices and Tips ### 1. Start with ShallowWithWarnings Mode diff --git a/src/PatternKit.Generators/PrototypeGenerator.cs b/src/PatternKit.Generators/PrototypeGenerator.cs index 3559ceb..29f72b1 100644 --- a/src/PatternKit.Generators/PrototypeGenerator.cs +++ b/src/PatternKit.Generators/PrototypeGenerator.cs @@ -61,7 +61,7 @@ public sealed class PrototypeGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor CustomStrategyMissingDescriptor = new( id: DiagIdCustomStrategyMissing, title: "Custom strategy requires partial clone hook, but none found", - messageFormat: "Member '{0}' has [PrototypeStrategy(Custom)] but no partial method 'private static partial {1} Clone{0}({1} value)' was found. Declare this method in your partial type.", + messageFormat: "Member '{0}' has [PrototypeStrategy(Custom)] but no static partial method '{1} Clone{0}({1} value)' was found. Declare this method in your partial type.", category: "PatternKit.Generators.Prototype", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs index 15a96a7..5c9728f 100644 --- a/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/PrototypeGeneratorTests.cs @@ -899,4 +899,110 @@ public abstract partial class AbstractBase var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); Assert.Contains(diagnostics, d => d.Id == "PKPRO010" && d.Severity == DiagnosticSeverity.Error); } + + [Fact] + public void SucceedWithCustomStrategy() + { + const string source = """ + using PatternKit.Generators.Prototype; + + namespace TestNamespace; + + public class CustomData + { + public string Value { get; set; } = ""; + } + + [Prototype] + public partial class Container + { + [PrototypeStrategy(PrototypeCloneStrategy.Custom)] + public CustomData Data { get; set; } = new(); + + private static partial CustomData CloneData(CustomData value); + } + + public partial class Container + { + private static partial CustomData CloneData(CustomData value) + { + return new CustomData { Value = value.Value + "_cloned" }; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(SucceedWithCustomStrategy)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should succeed - custom partial method is provided + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.DoesNotContain(diagnostics, d => d.Id == "PKPRO005"); + Assert.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); + + // Compilation should succeed + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify custom method is called + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Container.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("CloneData(this.Data)", generatedSource); + } + + [Fact] + public void SucceedWithDeepWhenPossibleMode() + { + const string source = """ + using PatternKit.Generators.Prototype; + using System.Collections.Generic; + + namespace TestNamespace; + + public class CloneableData + { + public string Value { get; set; } = ""; + public CloneableData Clone() => new CloneableData { Value = this.Value }; + } + + public class NonCloneableData + { + public string Value { get; set; } = ""; + } + + [Prototype(Mode = PrototypeMode.DeepWhenPossible)] + public partial class Container + { + // Should use Clone strategy automatically + public CloneableData Cloneable { get; set; } = new(); + + // Should fall back to by-reference (no warning in DeepWhenPossible mode) + public NonCloneableData NonCloneable { get; set; } = new(); + } + """; + + var comp = RoslynTestHelpers.CreateCompilation(source, nameof(SucceedWithDeepWhenPossibleMode)); + var gen = new PrototypeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated); + + // Should succeed - DeepWhenPossible mode clones what it can + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.DoesNotContain(diagnostics, d => d.Severity == DiagnosticSeverity.Error); + // No warnings in DeepWhenPossible mode + Assert.DoesNotContain(diagnostics, d => d.Id == "PKPRO003"); + + // Compilation should succeed + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify cloneable uses Clone() and non-cloneable uses by-reference + var generatedSource = result.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName == "Container.Prototype.g.cs") + .SourceText.ToString(); + Assert.Contains("Cloneable.Clone()", generatedSource); + Assert.Contains("this.NonCloneable", generatedSource); + } }