From 04424c45e403d350c1cd07b10e773e6719edf6ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:17:09 +0000 Subject: [PATCH 1/8] Initial plan From 997c67db0f47d037f2248aefe51c6c500c6bdbf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:22:53 +0000 Subject: [PATCH 2/8] Add Template Method pattern generator core implementation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../Template/HookPoint.cs | 23 + .../Template/TemplateAttribute.cs | 36 + .../Template/TemplateErrorPolicy.cs | 19 + .../Template/TemplateHookAttribute.cs | 28 + .../Template/TemplateStepAttribute.cs | 33 + .../TemplateGenerator.cs | 687 ++++++++++++++++++ 6 files changed, 826 insertions(+) create mode 100644 src/PatternKit.Generators.Abstractions/Template/HookPoint.cs create mode 100644 src/PatternKit.Generators.Abstractions/Template/TemplateAttribute.cs create mode 100644 src/PatternKit.Generators.Abstractions/Template/TemplateErrorPolicy.cs create mode 100644 src/PatternKit.Generators.Abstractions/Template/TemplateHookAttribute.cs create mode 100644 src/PatternKit.Generators.Abstractions/Template/TemplateStepAttribute.cs create mode 100644 src/PatternKit.Generators/TemplateGenerator.cs diff --git a/src/PatternKit.Generators.Abstractions/Template/HookPoint.cs b/src/PatternKit.Generators.Abstractions/Template/HookPoint.cs new file mode 100644 index 0000000..5ee4817 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Template/HookPoint.cs @@ -0,0 +1,23 @@ +namespace PatternKit.Generators.Template; + +/// +/// Defines the lifecycle points where hooks can be invoked in a template method workflow. +/// +public enum HookPoint +{ + /// + /// Invoked before any steps execute. + /// + BeforeAll = 0, + + /// + /// Invoked after all steps complete successfully. + /// + AfterAll = 1, + + /// + /// Invoked when any step throws an exception. + /// Receives the exception as a parameter. + /// + OnError = 2 +} diff --git a/src/PatternKit.Generators.Abstractions/Template/TemplateAttribute.cs b/src/PatternKit.Generators.Abstractions/Template/TemplateAttribute.cs new file mode 100644 index 0000000..44ed7a2 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Template/TemplateAttribute.cs @@ -0,0 +1,36 @@ +namespace PatternKit.Generators.Template; + +/// +/// Marks a partial type as a template method workflow host. +/// The generator will produce Execute/ExecuteAsync methods that invoke +/// steps and hooks in deterministic order. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class TemplateAttribute : Attribute +{ + /// + /// Name of the generated synchronous execute method. Default: "Execute". + /// + public string ExecuteMethodName { get; set; } = "Execute"; + + /// + /// Name of the generated asynchronous execute method. Default: "ExecuteAsync". + /// + public string ExecuteAsyncMethodName { get; set; } = "ExecuteAsync"; + + /// + /// Whether to generate the asynchronous Execute method. + /// If not specified, inferred from presence of ValueTask/CancellationToken in steps/hooks. + /// + public bool GenerateAsync { get; set; } + + /// + /// Forces generation of async method even if no async steps exist. + /// + public bool ForceAsync { get; set; } + + /// + /// Determines how errors are handled during template execution. + /// + public TemplateErrorPolicy ErrorPolicy { get; set; } = TemplateErrorPolicy.Rethrow; +} diff --git a/src/PatternKit.Generators.Abstractions/Template/TemplateErrorPolicy.cs b/src/PatternKit.Generators.Abstractions/Template/TemplateErrorPolicy.cs new file mode 100644 index 0000000..6edcf71 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Template/TemplateErrorPolicy.cs @@ -0,0 +1,19 @@ +namespace PatternKit.Generators.Template; + +/// +/// Defines how errors are handled during template method execution. +/// +public enum TemplateErrorPolicy +{ + /// + /// After invoking OnError hook (if present), rethrow the exception. + /// This is the default behavior. + /// + Rethrow = 0, + + /// + /// After invoking OnError hook (if present), continue execution with remaining steps. + /// Only allowed when all remaining steps are optional. + /// + HandleAndContinue = 1 +} diff --git a/src/PatternKit.Generators.Abstractions/Template/TemplateHookAttribute.cs b/src/PatternKit.Generators.Abstractions/Template/TemplateHookAttribute.cs new file mode 100644 index 0000000..91a20e7 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Template/TemplateHookAttribute.cs @@ -0,0 +1,28 @@ +namespace PatternKit.Generators.Template; + +/// +/// Marks a method as a hook in the template method workflow. +/// Hooks execute at specific lifecycle points (BeforeAll, AfterAll, OnError). +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class TemplateHookAttribute : Attribute +{ + /// + /// The hook point where this method should be invoked. + /// + public HookPoint HookPoint { get; } + + /// + /// Optional step order for future BeforeStep/AfterStep targeting (reserved for v2). + /// + public int? StepOrder { get; set; } + + /// + /// Initializes a new template hook with the specified hook point. + /// + /// When this hook should be invoked. + public TemplateHookAttribute(HookPoint hookPoint) + { + HookPoint = hookPoint; + } +} diff --git a/src/PatternKit.Generators.Abstractions/Template/TemplateStepAttribute.cs b/src/PatternKit.Generators.Abstractions/Template/TemplateStepAttribute.cs new file mode 100644 index 0000000..6edd2ab --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Template/TemplateStepAttribute.cs @@ -0,0 +1,33 @@ +namespace PatternKit.Generators.Template; + +/// +/// Marks a method as a step in the template method workflow. +/// Steps execute in ascending order. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class TemplateStepAttribute : Attribute +{ + /// + /// Execution order of this step. Required. Steps execute in ascending order. + /// + public int Order { get; } + + /// + /// Optional name for diagnostics and documentation. + /// + public string? Name { get; set; } + + /// + /// Whether this step is optional. Optional steps may be skipped in error scenarios. + /// + public bool Optional { get; set; } + + /// + /// Initializes a new template step with the specified execution order. + /// + /// The execution order (lower values execute first). + public TemplateStepAttribute(int order) + { + Order = order; + } +} diff --git a/src/PatternKit.Generators/TemplateGenerator.cs b/src/PatternKit.Generators/TemplateGenerator.cs new file mode 100644 index 0000000..2c8147e --- /dev/null +++ b/src/PatternKit.Generators/TemplateGenerator.cs @@ -0,0 +1,687 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Text; + +namespace PatternKit.Generators; + +/// +/// Source generator for the Template Method pattern. +/// Generates Execute/ExecuteAsync methods that invoke steps and hooks in deterministic order. +/// +[Generator] +public sealed class TemplateGenerator : IIncrementalGenerator +{ + // Diagnostic IDs + private const string DiagIdTypeNotPartial = "PKTMP001"; + private const string DiagIdNoSteps = "PKTMP002"; + private const string DiagIdDuplicateOrder = "PKTMP003"; + private const string DiagIdInvalidStepSignature = "PKTMP004"; + private const string DiagIdInvalidHookSignature = "PKTMP005"; + private const string DiagIdMixedAsyncSignatures = "PKTMP006"; + private const string DiagIdMissingCancellationToken = "PKTMP007"; + private const string DiagIdHandleAndContinuePolicy = "PKTMP008"; + + private static readonly DiagnosticDescriptor TypeNotPartialDescriptor = new( + id: DiagIdTypeNotPartial, + title: "Type marked with [Template] must be partial", + messageFormat: "Type '{0}' is marked with [Template] but is not declared as partial. Add the 'partial' keyword to the type declaration.", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor NoStepsDescriptor = new( + id: DiagIdNoSteps, + title: "No template steps found", + messageFormat: "Type '{0}' has [Template] but no methods marked with [TemplateStep]. At least one step is required.", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateOrderDescriptor = new( + id: DiagIdDuplicateOrder, + title: "Duplicate step order detected", + messageFormat: "Multiple steps have Order={0} in type '{1}'. Step orders must be unique. Conflicting steps: {2}", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidStepSignatureDescriptor = new( + id: DiagIdInvalidStepSignature, + title: "Invalid step method signature", + messageFormat: "Step method '{0}' has an invalid signature. Steps must return void or ValueTask and accept a context parameter (optionally with CancellationToken for async).", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidHookSignatureDescriptor = new( + id: DiagIdInvalidHookSignature, + title: "Invalid hook method signature", + messageFormat: "Hook method '{0}' has an invalid signature. {1}", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MixedAsyncSignaturesDescriptor = new( + id: DiagIdMixedAsyncSignatures, + title: "Mixed sync/async signatures detected", + messageFormat: "Type '{0}' has mixed synchronous and asynchronous steps/hooks. All steps must consistently return void or ValueTask.", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor MissingCancellationTokenDescriptor = new( + id: DiagIdMissingCancellationToken, + title: "CancellationToken parameter required for async step", + messageFormat: "Async step method '{0}' should accept a CancellationToken parameter for proper cancellation support.", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor HandleAndContinuePolicyDescriptor = new( + id: DiagIdHandleAndContinuePolicy, + title: "HandleAndContinue policy not allowed with non-optional steps", + messageFormat: "ErrorPolicy=HandleAndContinue is not allowed when non-optional steps remain after step '{0}'. Make remaining steps optional or use ErrorPolicy=Rethrow.", + category: "PatternKit.Generators.Template", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all type declarations with [Template] attribute + var templateTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Template.TemplateAttribute", + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + // Generate for each type + context.RegisterSourceOutput(templateTypes, (spc, typeContext) => + { + if (typeContext.TargetSymbol is not INamedTypeSymbol typeSymbol) + return; + + var attr = typeContext.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Template.TemplateAttribute"); + if (attr is null) + return; + + GenerateTemplateForType(spc, typeSymbol, attr, typeContext.TargetNode); + }); + } + + private void GenerateTemplateForType( + 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 configuration + var config = ParseTemplateConfig(attribute); + + // Collect steps and hooks + var steps = CollectSteps(typeSymbol, context); + var hooks = CollectHooks(typeSymbol, context); + + // Validate steps exist + if (steps.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create( + NoStepsDescriptor, + node.GetLocation(), + typeSymbol.Name)); + return; + } + + // Validate step ordering + if (!ValidateStepOrdering(steps, typeSymbol, context)) + return; + + // Validate signatures + if (!ValidateSignatures(steps, hooks, typeSymbol, context)) + return; + + // Determine if async generation is needed + var needsAsync = config.ForceAsync || + config.GenerateAsync || + DetermineIfAsync(steps, hooks); + + // Validate error policy + if (config.ErrorPolicy == 1 && !ValidateHandleAndContinuePolicy(steps, context)) + return; + + // Generate the template method implementation + var source = GenerateTemplateMethod(typeSymbol, config, steps, hooks, needsAsync); + var fileName = $"{typeSymbol.Name}.Template.g.cs"; + context.AddSource(fileName, source); + } + + 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 TemplateConfig ParseTemplateConfig(AttributeData attribute) + { + var config = new TemplateConfig(); + + foreach (var namedArg in attribute.NamedArguments) + { + switch (namedArg.Key) + { + case "ExecuteMethodName": + config.ExecuteMethodName = namedArg.Value.Value?.ToString() ?? "Execute"; + break; + case "ExecuteAsyncMethodName": + config.ExecuteAsyncMethodName = namedArg.Value.Value?.ToString() ?? "ExecuteAsync"; + break; + case "GenerateAsync": + config.GenerateAsync = namedArg.Value.Value is bool b && b; + break; + case "ForceAsync": + config.ForceAsync = namedArg.Value.Value is bool f && f; + break; + case "ErrorPolicy": + config.ErrorPolicy = namedArg.Value.Value is int policy ? policy : 0; + break; + } + } + + return config; + } + + private ImmutableArray CollectSteps(INamedTypeSymbol typeSymbol, SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var member in typeSymbol.GetMembers()) + { + if (member is not IMethodSymbol method) + continue; + + var stepAttr = method.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Template.TemplateStepAttribute"); + + if (stepAttr is null) + continue; + + // Extract order from constructor argument + var order = stepAttr.ConstructorArguments.Length > 0 && + stepAttr.ConstructorArguments[0].Value is int o ? o : 0; + + string? name = null; + var optional = false; + + foreach (var namedArg in stepAttr.NamedArguments) + { + if (namedArg.Key == "Name") + name = namedArg.Value.Value?.ToString(); + else if (namedArg.Key == "Optional") + optional = namedArg.Value.Value is bool b && b; + } + + builder.Add(new StepModel + { + Method = method, + Order = order, + Name = name ?? method.Name, + Optional = optional + }); + } + + return builder.ToImmutable(); + } + + private ImmutableArray CollectHooks(INamedTypeSymbol typeSymbol, SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var member in typeSymbol.GetMembers()) + { + if (member is not IMethodSymbol method) + continue; + + var hookAttr = method.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Template.TemplateHookAttribute"); + + if (hookAttr is null) + continue; + + // Extract hook point from constructor argument + var hookPoint = hookAttr.ConstructorArguments.Length > 0 && + hookAttr.ConstructorArguments[0].Value is int hp ? hp : 0; + + builder.Add(new HookModel + { + Method = method, + HookPoint = hookPoint + }); + } + + return builder.ToImmutable(); + } + + private bool ValidateStepOrdering( + ImmutableArray steps, + INamedTypeSymbol typeSymbol, + SourceProductionContext context) + { + var orderGroups = steps.GroupBy(s => s.Order).Where(g => g.Count() > 1); + foreach (var group in orderGroups) + { + var stepNames = string.Join(", ", group.Select(s => s.Name)); + context.ReportDiagnostic(Diagnostic.Create( + DuplicateOrderDescriptor, + Location.None, + group.Key, + typeSymbol.Name, + stepNames)); + return false; + } + return true; + } + + private bool ValidateSignatures( + ImmutableArray steps, + ImmutableArray hooks, + INamedTypeSymbol typeSymbol, + SourceProductionContext context) + { + // Validate step signatures + foreach (var step in steps) + { + if (!ValidateStepSignature(step.Method, context)) + return false; + } + + // Validate hook signatures + foreach (var hook in hooks) + { + if (!ValidateHookSignature(hook.Method, hook.HookPoint, context)) + return false; + } + + return true; + } + + private bool ValidateStepSignature(IMethodSymbol method, SourceProductionContext context) + { + // Step must return void or ValueTask + var returnsVoid = method.ReturnsVoid; + var returnsValueTask = method.ReturnType.Name == "ValueTask" && + method.ReturnType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; + + if (!returnsVoid && !returnsValueTask) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidStepSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + // Step must have at least one parameter (context) + if (method.Parameters.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidStepSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + return false; + } + + // If async, recommend CancellationToken parameter + if (returnsValueTask && method.Parameters.Length == 1) + { + context.ReportDiagnostic(Diagnostic.Create( + MissingCancellationTokenDescriptor, + method.Locations.FirstOrDefault(), + method.Name)); + } + + return true; + } + + private bool ValidateHookSignature(IMethodSymbol method, int hookPoint, SourceProductionContext context) + { + // Hook must return void or ValueTask + var returnsVoid = method.ReturnsVoid; + var returnsValueTask = method.ReturnType.Name == "ValueTask" && + method.ReturnType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; + + if (!returnsVoid && !returnsValueTask) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidHookSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name, + "Hook must return void or ValueTask.")); + return false; + } + + // OnError hook must accept Exception parameter + if (hookPoint == 2) // OnError + { + if (method.Parameters.Length < 2) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidHookSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name, + "OnError hook must accept context and Exception parameters.")); + return false; + } + } + else + { + // BeforeAll/AfterAll hooks need at least context parameter + if (method.Parameters.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidHookSignatureDescriptor, + method.Locations.FirstOrDefault(), + method.Name, + "Hook must accept at least a context parameter.")); + return false; + } + } + + return true; + } + + private bool DetermineIfAsync(ImmutableArray steps, ImmutableArray hooks) + { + // Check if any step or hook returns ValueTask or accepts CancellationToken + foreach (var step in steps) + { + if (step.Method.ReturnType.Name == "ValueTask") + return true; + if (step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken")) + return true; + } + + foreach (var hook in hooks) + { + if (hook.Method.ReturnType.Name == "ValueTask") + return true; + if (hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken")) + return true; + } + + return false; + } + + private bool ValidateHandleAndContinuePolicy( + ImmutableArray steps, + SourceProductionContext context) + { + // For HandleAndContinue, verify all steps are optional + var nonOptionalSteps = steps.Where(s => !s.Optional).ToList(); + if (nonOptionalSteps.Count > 0) + { + var firstNonOptional = nonOptionalSteps.First(); + context.ReportDiagnostic(Diagnostic.Create( + HandleAndContinuePolicyDescriptor, + firstNonOptional.Method.Locations.FirstOrDefault(), + firstNonOptional.Name)); + return false; + } + + return true; + } + + private string GenerateTemplateMethod( + INamedTypeSymbol typeSymbol, + TemplateConfig config, + ImmutableArray steps, + ImmutableArray hooks, + bool needsAsync) + { + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? "GlobalNamespace" + : typeSymbol.ContainingNamespace.ToDisplayString(); + + var typeName = typeSymbol.Name; + var typeKind = typeSymbol.TypeKind == TypeKind.Struct ? "struct" : "class"; + var recordKeyword = typeSymbol.IsRecord ? "record " : ""; + + // Sort steps by order + var sortedSteps = steps.OrderBy(s => s.Order).ThenBy(s => s.Name).ToList(); + + // Get context type from first step + var contextType = sortedSteps[0].Method.Parameters[0].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Group hooks + var beforeAllHooks = hooks.Where(h => h.HookPoint == 0).ToList(); + var afterAllHooks = hooks.Where(h => h.HookPoint == 1).ToList(); + var onErrorHooks = hooks.Where(h => h.HookPoint == 2).ToList(); + + var sb = new StringBuilder(); + + // Header + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + if (ns != "GlobalNamespace") + { + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); + } + + // Type declaration + sb.AppendLine($"partial {recordKeyword}{typeKind} {typeName}"); + sb.AppendLine("{"); + + // Generate synchronous Execute method + sb.AppendLine($" public void {config.ExecuteMethodName}({contextType} ctx)"); + sb.AppendLine(" {"); + + // BeforeAll hooks + foreach (var hook in beforeAllHooks) + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } + + // Error handling wrapper if OnError hooks exist + if (onErrorHooks.Count > 0) + { + sb.AppendLine(" try"); + sb.AppendLine(" {"); + + // Steps + foreach (var step in sortedSteps) + { + sb.AppendLine($" {step.Method.Name}(ctx);"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + + // OnError hooks + foreach (var hook in onErrorHooks) + { + sb.AppendLine($" {hook.Method.Name}(ctx, ex);"); + } + + if (config.ErrorPolicy == 0) // Rethrow + { + sb.AppendLine(" throw;"); + } + + sb.AppendLine(" }"); + } + else + { + // Steps without try-catch + foreach (var step in sortedSteps) + { + sb.AppendLine($" {step.Method.Name}(ctx);"); + } + } + + // AfterAll hooks + foreach (var hook in afterAllHooks) + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } + + sb.AppendLine(" }"); + + // Generate asynchronous ExecuteAsync method if needed + if (needsAsync) + { + sb.AppendLine(); + sb.AppendLine($" public async System.Threading.Tasks.ValueTask {config.ExecuteAsyncMethodName}({contextType} ctx, System.Threading.CancellationToken ct = default)"); + sb.AppendLine(" {"); + + // BeforeAll hooks + foreach (var hook in beforeAllHooks) + { + var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + if (isAsync) + { + var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var args = hasCt ? "ctx, ct" : "ctx"; + sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); + } + else + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } + } + + // Error handling wrapper if OnError hooks exist + if (onErrorHooks.Count > 0) + { + sb.AppendLine(" try"); + sb.AppendLine(" {"); + + // Steps + foreach (var step in sortedSteps) + { + var isAsync = step.Method.ReturnType.Name == "ValueTask"; + if (isAsync) + { + var hasCt = step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var args = hasCt ? "ctx, ct" : "ctx"; + sb.AppendLine($" await {step.Method.Name}({args}).ConfigureAwait(false);"); + } + else + { + sb.AppendLine($" {step.Method.Name}(ctx);"); + } + } + + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + + // OnError hooks + foreach (var hook in onErrorHooks) + { + var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + if (isAsync) + { + var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var args = hasCt ? "ctx, ex, ct" : "ctx, ex"; + sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); + } + else + { + sb.AppendLine($" {hook.Method.Name}(ctx, ex);"); + } + } + + if (config.ErrorPolicy == 0) // Rethrow + { + sb.AppendLine(" throw;"); + } + + sb.AppendLine(" }"); + } + else + { + // Steps without try-catch + foreach (var step in sortedSteps) + { + var isAsync = step.Method.ReturnType.Name == "ValueTask"; + if (isAsync) + { + var hasCt = step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var args = hasCt ? "ctx, ct" : "ctx"; + sb.AppendLine($" await {step.Method.Name}({args}).ConfigureAwait(false);"); + } + else + { + sb.AppendLine($" {step.Method.Name}(ctx);"); + } + } + } + + // AfterAll hooks + foreach (var hook in afterAllHooks) + { + var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + if (isAsync) + { + var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var args = hasCt ? "ctx, ct" : "ctx"; + sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); + } + else + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } + } + + sb.AppendLine(" }"); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + // Helper classes + private class TemplateConfig + { + public string ExecuteMethodName { get; set; } = "Execute"; + public string ExecuteAsyncMethodName { get; set; } = "ExecuteAsync"; + public bool GenerateAsync { get; set; } + public bool ForceAsync { get; set; } + public int ErrorPolicy { get; set; } // 0 = Rethrow, 1 = HandleAndContinue + } + + private class StepModel + { + public IMethodSymbol Method { get; set; } = null!; + public int Order { get; set; } + public string Name { get; set; } = null!; + public bool Optional { get; set; } + } + + private class HookModel + { + public IMethodSymbol Method { get; set; } = null!; + public int HookPoint { get; set; } // 0=BeforeAll, 1=AfterAll, 2=OnError + } +} From aa8fbef422cd7da36040c4e9a10239cf86b2deeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:27:50 +0000 Subject: [PATCH 3/8] Add comprehensive Template generator tests - all passing Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../TemplateGenerator.cs | 88 +- .../TemplateGeneratorTests.cs | 757 ++++++++++++++++++ 2 files changed, 802 insertions(+), 43 deletions(-) create mode 100644 test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs diff --git a/src/PatternKit.Generators/TemplateGenerator.cs b/src/PatternKit.Generators/TemplateGenerator.cs index 2c8147e..b4a197e 100644 --- a/src/PatternKit.Generators/TemplateGenerator.cs +++ b/src/PatternKit.Generators/TemplateGenerator.cs @@ -490,62 +490,64 @@ private string GenerateTemplateMethod( sb.AppendLine($"partial {recordKeyword}{typeKind} {typeName}"); sb.AppendLine("{"); - // Generate synchronous Execute method - sb.AppendLine($" public void {config.ExecuteMethodName}({contextType} ctx)"); - sb.AppendLine(" {"); - - // BeforeAll hooks - foreach (var hook in beforeAllHooks) + // Generate synchronous Execute method only if no async methods + if (!needsAsync) { - sb.AppendLine($" {hook.Method.Name}(ctx);"); - } + sb.AppendLine($" public void {config.ExecuteMethodName}({contextType} ctx)"); + sb.AppendLine(" {"); - // Error handling wrapper if OnError hooks exist - if (onErrorHooks.Count > 0) - { - sb.AppendLine(" try"); - sb.AppendLine(" {"); - - // Steps - foreach (var step in sortedSteps) + // BeforeAll hooks + foreach (var hook in beforeAllHooks) { - sb.AppendLine($" {step.Method.Name}(ctx);"); + sb.AppendLine($" {hook.Method.Name}(ctx);"); } - sb.AppendLine(" }"); - sb.AppendLine(" catch (System.Exception ex)"); - sb.AppendLine(" {"); - - // OnError hooks - foreach (var hook in onErrorHooks) + // Error handling wrapper if OnError hooks exist + if (onErrorHooks.Count > 0) { - sb.AppendLine($" {hook.Method.Name}(ctx, ex);"); - } + sb.AppendLine(" try"); + sb.AppendLine(" {"); + + // Steps + foreach (var step in sortedSteps) + { + sb.AppendLine($" {step.Method.Name}(ctx);"); + } + + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + + // OnError hooks + foreach (var hook in onErrorHooks) + { + sb.AppendLine($" {hook.Method.Name}(ctx, ex);"); + } - if (config.ErrorPolicy == 0) // Rethrow + if (config.ErrorPolicy == 0) // Rethrow + { + sb.AppendLine(" throw;"); + } + + sb.AppendLine(" }"); + } + else { - sb.AppendLine(" throw;"); + // Steps without try-catch + foreach (var step in sortedSteps) + { + sb.AppendLine($" {step.Method.Name}(ctx);"); + } } - - sb.AppendLine(" }"); - } - else - { - // Steps without try-catch - foreach (var step in sortedSteps) + + // AfterAll hooks + foreach (var hook in afterAllHooks) { - sb.AppendLine($" {step.Method.Name}(ctx);"); + sb.AppendLine($" {hook.Method.Name}(ctx);"); } - } - // AfterAll hooks - foreach (var hook in afterAllHooks) - { - sb.AppendLine($" {hook.Method.Name}(ctx);"); + sb.AppendLine(" }"); } - - sb.AppendLine(" }"); - // Generate asynchronous ExecuteAsync method if needed if (needsAsync) { diff --git a/test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs b/test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs new file mode 100644 index 0000000..0b658e8 --- /dev/null +++ b/test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs @@ -0,0 +1,757 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Common; +using PatternKit.Creational.Builder; + +namespace PatternKit.Generators.Tests; + +/// +/// Comprehensive tests for the Template Method Pattern generator. +/// +public class TemplateGeneratorTests +{ + #region Basic Template Tests + + [Fact] + public void Generates_Basic_Template_Without_Diagnostics() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext + { + public string Data { get; set; } = ""; + } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) { } + + [TemplateStep(2)] + private void Persist(ImportContext ctx) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Basic_Template_Without_Diagnostics)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + // No generator diagnostics + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + // Confirm we generated the expected file + var names = run.Results.SelectMany(r => r.GeneratedSources).Select(gs => gs.HintName).ToArray(); + Assert.Contains("ImportWorkflow.Template.g.cs", names); + + // Verify compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Generated_Execute_Method_Compiles_Successfully() + { + var source = """ + using PatternKit.Generators.Template; + using System.Collections.Generic; + + namespace PatternKit.Examples; + + public class ImportContext + { + public List Log { get; } = new(); + } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) + { + ctx.Log.Add("Validate"); + } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) + { + ctx.Log.Add("Transform"); + } + + [TemplateStep(2)] + private void Persist(ImportContext ctx) + { + ctx.Log.Add("Persist"); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generated_Execute_Method_Compiles_Successfully)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + // No diagnostics + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + // Get generated source + var generatedSource = run.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("ImportWorkflow")) + .SourceText.ToString(); + + // Verify Execute method exists + Assert.Contains("public void Execute(", generatedSource); + Assert.Contains("Validate(ctx);", generatedSource); + Assert.Contains("Transform(ctx);", generatedSource); + Assert.Contains("Persist(ctx);", generatedSource); + + // Verify compilation succeeds + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + #endregion + + #region Hook Tests + + [Fact] + public void Generates_Template_With_BeforeAll_Hook() + { + var source = """ + using PatternKit.Generators.Template; + using System.Collections.Generic; + + namespace PatternKit.Examples; + + public class ImportContext + { + public List Log { get; } = new(); + } + + [Template] + public partial class ImportWorkflow + { + [TemplateHook(HookPoint.BeforeAll)] + private void OnStart(ImportContext ctx) + { + ctx.Log.Add("BeforeAll"); + } + + [TemplateStep(0)] + private void Validate(ImportContext ctx) + { + ctx.Log.Add("Validate"); + } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) + { + ctx.Log.Add("Transform"); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Template_With_BeforeAll_Hook)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify BeforeAll hook is called first + var generatedSource = run.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("ImportWorkflow")) + .SourceText.ToString(); + + var onStartIndex = generatedSource.IndexOf("OnStart(ctx);"); + var validateIndex = generatedSource.IndexOf("Validate(ctx);"); + Assert.True(onStartIndex < validateIndex, "BeforeAll hook should be called before steps"); + } + + [Fact] + public void Generates_Template_With_AfterAll_Hook() + { + var source = """ + using PatternKit.Generators.Template; + using System.Collections.Generic; + + namespace PatternKit.Examples; + + public class ImportContext + { + public List Log { get; } = new(); + } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) + { + ctx.Log.Add("Validate"); + } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) + { + ctx.Log.Add("Transform"); + } + + [TemplateHook(HookPoint.AfterAll)] + private void OnComplete(ImportContext ctx) + { + ctx.Log.Add("AfterAll"); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Template_With_AfterAll_Hook)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify AfterAll hook is called last + var generatedSource = run.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("ImportWorkflow")) + .SourceText.ToString(); + + var transformIndex = generatedSource.LastIndexOf("Transform(ctx);"); + var onCompleteIndex = generatedSource.IndexOf("OnComplete(ctx);"); + Assert.True(transformIndex < onCompleteIndex, "AfterAll hook should be called after steps"); + } + + [Fact] + public void Generates_Template_With_OnError_Hook() + { + var source = """ + using PatternKit.Generators.Template; + using System; + using System.Collections.Generic; + + namespace PatternKit.Examples; + + public class ImportContext + { + public List Log { get; } = new(); + } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) + { + ctx.Log.Add("Validate"); + } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) + { + ctx.Log.Add("Transform"); + throw new InvalidOperationException("Test error"); + } + + [TemplateHook(HookPoint.OnError)] + private void OnError(ImportContext ctx, Exception ex) + { + ctx.Log.Add($"OnError:{ex.Message}"); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Template_With_OnError_Hook)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify OnError hook is in try-catch block + var generatedSource = run.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("ImportWorkflow")) + .SourceText.ToString(); + + Assert.Contains("try", generatedSource); + Assert.Contains("catch (System.Exception ex)", generatedSource); + Assert.Contains("OnError(ctx, ex);", generatedSource); + Assert.Contains("throw;", generatedSource); // Rethrow policy + } + + #endregion + + #region Async Tests + + [Fact] + public void Generates_Async_Template_With_ValueTask() + { + var source = """ + using PatternKit.Generators.Template; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + namespace PatternKit.Examples; + + public class ImportContext + { + public List Log { get; } = new(); + } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private ValueTask ValidateAsync(ImportContext ctx, CancellationToken ct) + { + ctx.Log.Add("Validate"); + return ValueTask.CompletedTask; + } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) + { + ctx.Log.Add("Transform"); + } + + [TemplateStep(2)] + private ValueTask PersistAsync(ImportContext ctx, CancellationToken ct) + { + ctx.Log.Add("Persist"); + return ValueTask.CompletedTask; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Async_Template_With_ValueTask)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify ExecuteAsync method is generated + var generatedSource = run.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("ImportWorkflow")) + .SourceText.ToString(); + + Assert.Contains("public async System.Threading.Tasks.ValueTask ExecuteAsync(", generatedSource); + Assert.Contains("await ValidateAsync(ctx, ct).ConfigureAwait(false);", generatedSource); + Assert.Contains("Transform(ctx);", generatedSource); + Assert.Contains("await PersistAsync(ctx, ct).ConfigureAwait(false);", generatedSource); + } + + [Fact] + public void Generates_Async_Template_With_ForceAsync() + { + var source = """ + using PatternKit.Generators.Template; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + namespace PatternKit.Examples; + + public class ImportContext + { + public List Log { get; } = new(); + } + + [Template(ForceAsync = true)] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) + { + ctx.Log.Add("Validate"); + } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) + { + ctx.Log.Add("Transform"); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Async_Template_With_ForceAsync)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify ExecuteAsync method is generated even for sync steps + var generatedSource = run.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("ImportWorkflow")) + .SourceText.ToString(); + + Assert.Contains("public async System.Threading.Tasks.ValueTask ExecuteAsync(", generatedSource); + Assert.Contains("Validate(ctx);", generatedSource); + Assert.Contains("Transform(ctx);", generatedSource); + } + + #endregion + + #region Type Target Tests + + [Fact] + public void Generates_Template_For_Struct() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext + { + public string Data { get; set; } = ""; + } + + [Template] + public partial struct ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Template_For_Struct)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Generates_Template_For_Record_Class() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext + { + public string Data { get; set; } = ""; + } + + [Template] + public partial record class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Template_For_Record_Class)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Generates_Template_For_Record_Struct() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext + { + public string Data { get; set; } = ""; + } + + [Template] + public partial record struct ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateStep(1)] + private void Transform(ImportContext ctx) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Template_For_Record_Struct)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + #endregion + + #region Diagnostic Tests + + [Fact] + public void Reports_Error_When_Type_Not_Partial() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_Type_Not_Partial)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP001"); + } + + [Fact] + public void Reports_Error_When_No_Steps() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public partial class ImportWorkflow + { + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_No_Steps)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP002"); + } + + [Fact] + public void Reports_Error_When_Duplicate_Step_Order() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateStep(0)] + private void Transform(ImportContext ctx) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_Duplicate_Step_Order)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP003"); + } + + [Fact] + public void Reports_Error_When_Invalid_Step_Signature() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private int Validate(ImportContext ctx) { return 0; } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_Invalid_Step_Signature)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP004"); + } + + [Fact] + public void Reports_Warning_When_Async_Step_Missing_CancellationToken() + { + var source = """ + using PatternKit.Generators.Template; + using System.Threading.Tasks; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private ValueTask ValidateAsync(ImportContext ctx) + { + return ValueTask.CompletedTask; + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Warning_When_Async_Step_Missing_CancellationToken)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP007"); + } + + #endregion + + #region Custom Method Names + + [Fact] + public void Generates_Template_With_Custom_Method_Names() + { + var source = """ + using PatternKit.Generators.Template; + using System.Collections.Generic; + + namespace PatternKit.Examples; + + public class ImportContext + { + public List Log { get; } = new(); + } + + [Template(ExecuteMethodName = "Process", ExecuteAsyncMethodName = "ProcessAsync")] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) + { + ctx.Log.Add("Validate"); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Generates_Template_With_Custom_Method_Names)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Verify custom method name is used + var generatedSource = run.Results + .SelectMany(r => r.GeneratedSources) + .First(gs => gs.HintName.Contains("ImportWorkflow")) + .SourceText.ToString(); + + Assert.Contains("public void Process(", generatedSource); + Assert.DoesNotContain("public void Execute(", generatedSource); + } + + #endregion +} From 08c3760223563731b64bcbdb9e1a4067bf4b9ae5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:34:49 +0000 Subject: [PATCH 4/8] Add Template Method examples and BDD tests (generator issue to debug) Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../ImportWorkflowDemo.cs | 199 +++++++++++++++++ .../OrderProcessingDemo.cs | 211 ++++++++++++++++++ .../ImportWorkflowDemoTests.cs | 63 ++++++ .../OrderProcessingDemoTests.cs | 77 +++++++ 4 files changed, 550 insertions(+) create mode 100644 src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs create mode 100644 src/PatternKit.Examples/TemplateMethodGeneratorDemo/OrderProcessingDemo.cs create mode 100644 test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/ImportWorkflowDemoTests.cs create mode 100644 test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/OrderProcessingDemoTests.cs diff --git a/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs new file mode 100644 index 0000000..57fd66b --- /dev/null +++ b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs @@ -0,0 +1,199 @@ +using PatternKit.Generators.Template; + +namespace PatternKit.Examples.TemplateMethodGeneratorDemo; + +/// +/// Context for data import workflow - contains all state for the import process. +/// +public class ImportContext +{ + public string FilePath { get; set; } = ""; + public string[] RawData { get; set; } = Array.Empty(); + public List Records { get; set; } = new(); + public List Log { get; set; } = new(); + public bool ValidationPassed { get; set; } +} + +/// +/// Represents a single data record after transformation. +/// +public record DataRecord(string Name, string Value); + +/// +/// Import workflow using Template Method pattern via source generator. +/// This demonstrates a realistic data import pipeline with validation, transformation, and persistence. +/// +[Template] +public partial class ImportWorkflow +{ + /// + /// Invoked before any steps execute. Sets up the import context. + /// + [TemplateHook(HookPoint.BeforeAll)] + private void OnStart(ImportContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Starting import from: {ctx.FilePath}"); + } + + /// + /// Step 1: Load raw data from the source. + /// + [TemplateStep(0, Name = "Load")] + private void LoadData(ImportContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Loading data..."); + + // Simulate loading from file + if (File.Exists(ctx.FilePath)) + { + ctx.RawData = File.ReadAllLines(ctx.FilePath); + } + else + { + // For demo purposes, create sample data + ctx.RawData = new[] + { + "Name:Alice;Value:100", + "Name:Bob;Value:200", + "Name:Charlie;Value:300" + }; + } + + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Loaded {ctx.RawData.Length} lines"); + } + + /// + /// Step 2: Validate the loaded data. + /// + [TemplateStep(1, Name = "Validate")] + private void ValidateData(ImportContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Validating data..."); + + // Simple validation: ensure each line has required format + var invalidLines = ctx.RawData + .Where(line => !line.Contains("Name:") || !line.Contains("Value:")) + .ToList(); + + if (invalidLines.Any()) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] ERROR: Found {invalidLines.Count} invalid lines"); + ctx.ValidationPassed = false; + throw new InvalidOperationException($"Validation failed: {invalidLines.Count} invalid lines"); + } + + ctx.ValidationPassed = true; + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Validation passed"); + } + + /// + /// Step 3: Transform raw data into structured records. + /// + [TemplateStep(2, Name = "Transform")] + private void TransformData(ImportContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Transforming data..."); + + foreach (var line in ctx.RawData) + { + var parts = line.Split(';'); + var name = parts[0].Split(':')[1]; + var value = parts[1].Split(':')[1]; + + ctx.Records.Add(new DataRecord(name, value)); + } + + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Transformed {ctx.Records.Count} records"); + } + + /// + /// Step 4: Persist the transformed records. + /// + [TemplateStep(3, Name = "Persist")] + private void PersistData(ImportContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Persisting data..."); + + // Simulate persistence + foreach (var record in ctx.Records) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Saved: {record.Name} = {record.Value}"); + } + + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Persisted {ctx.Records.Count} records"); + } + + /// + /// Invoked when any step throws an exception. + /// + [TemplateHook(HookPoint.OnError)] + private void OnError(ImportContext ctx, Exception ex) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] ERROR: {ex.Message}"); + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Import failed at validation stage"); + } + + /// + /// Invoked after all steps complete successfully. + /// + [TemplateHook(HookPoint.AfterAll)] + private void OnComplete(ImportContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Import completed successfully"); + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Summary: {ctx.Records.Count} records imported"); + } +} + +/// +/// Demo runner that executes the import workflow. +/// +public static class ImportWorkflowDemo +{ + public static List Run(string? filePath = null) + { + var ctx = new ImportContext + { + FilePath = filePath ?? "sample.csv" + }; + + var workflow = new ImportWorkflow(); + + try + { + workflow.Execute(ctx); + } + catch (Exception) + { + // Exception was logged by OnError hook + } + + return ctx.Log; + } + + public static List RunWithInvalidData() + { + var ctx = new ImportContext + { + FilePath = "invalid.csv", + RawData = new[] + { + "Name:Alice;Value:100", + "InvalidLine", // This will cause validation to fail + "Name:Bob;Value:200" + } + }; + + var workflow = new ImportWorkflow(); + + try + { + workflow.Execute(ctx); + } + catch (Exception) + { + // Exception was logged by OnError hook + } + + return ctx.Log; + } +} diff --git a/src/PatternKit.Examples/TemplateMethodGeneratorDemo/OrderProcessingDemo.cs b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/OrderProcessingDemo.cs new file mode 100644 index 0000000..1013b50 --- /dev/null +++ b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/OrderProcessingDemo.cs @@ -0,0 +1,211 @@ +using PatternKit.Generators.Template; + +namespace PatternKit.Examples.TemplateMethodGeneratorDemo; + +/// +/// Context for async order processing workflow. +/// +public class OrderContext +{ + public string OrderId { get; set; } = ""; + public decimal Amount { get; set; } + public string Customer { get; set; } = ""; + public List Log { get; set; } = new(); + public bool PaymentAuthorized { get; set; } + public bool InventoryReserved { get; set; } + public bool OrderConfirmed { get; set; } +} + +/// +/// Async order processing workflow using Template Method pattern. +/// Demonstrates async/await with ValueTask and CancellationToken support. +/// +[Template(GenerateAsync = true)] +public partial class OrderProcessingWorkflow +{ + /// + /// Invoked before processing starts. + /// + [TemplateHook(HookPoint.BeforeAll)] + private void OnStart(OrderContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Starting order processing for Order #{ctx.OrderId}"); + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Customer: {ctx.Customer}, Amount: ${ctx.Amount:F2}"); + } + + /// + /// Step 1: Authorize payment asynchronously. + /// + [TemplateStep(0, Name = "AuthorizePayment")] + private async ValueTask AuthorizePaymentAsync(OrderContext ctx, CancellationToken ct) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Authorizing payment..."); + + // Simulate async payment authorization + await Task.Delay(100, ct); + + if (ctx.Amount > 0) + { + ctx.PaymentAuthorized = true; + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Payment authorized: ${ctx.Amount:F2}"); + } + else + { + throw new InvalidOperationException("Invalid payment amount"); + } + } + + /// + /// Step 2: Reserve inventory asynchronously. + /// + [TemplateStep(1, Name = "ReserveInventory")] + private async ValueTask ReserveInventoryAsync(OrderContext ctx, CancellationToken ct) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Reserving inventory..."); + + // Simulate async inventory check + await Task.Delay(100, ct); + + ctx.InventoryReserved = true; + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Inventory reserved"); + } + + /// + /// Step 3: Confirm order (synchronous step in async workflow). + /// + [TemplateStep(2, Name = "ConfirmOrder")] + private void ConfirmOrder(OrderContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Confirming order..."); + + if (ctx.PaymentAuthorized && ctx.InventoryReserved) + { + ctx.OrderConfirmed = true; + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Order confirmed: #{ctx.OrderId}"); + } + } + + /// + /// Step 4: Send notification email asynchronously. + /// + [TemplateStep(3, Name = "SendNotification")] + private async ValueTask SendNotificationAsync(OrderContext ctx, CancellationToken ct) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Sending notification to {ctx.Customer}..."); + + // Simulate async email sending + await Task.Delay(100, ct); + + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Notification sent"); + } + + /// + /// Invoked when any step fails. + /// + [TemplateHook(HookPoint.OnError)] + private void OnError(OrderContext ctx, Exception ex) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] ERROR: {ex.Message}"); + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Order processing failed"); + + // Compensating actions would go here (e.g., release inventory, refund payment) + if (ctx.InventoryReserved) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Rolling back inventory reservation"); + } + + if (ctx.PaymentAuthorized) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Rolling back payment authorization"); + } + } + + /// + /// Invoked after successful completion. + /// + [TemplateHook(HookPoint.AfterAll)] + private void OnComplete(OrderContext ctx) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Order processing completed successfully"); + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Order #{ctx.OrderId} is ready for fulfillment"); + } +} + +/// +/// Demo runner for async order processing workflow. +/// +public static class OrderProcessingDemo +{ + public static async Task> RunAsync(string orderId = "ORD-001", string customer = "John Doe", decimal amount = 99.99m) + { + var ctx = new OrderContext + { + OrderId = orderId, + Customer = customer, + Amount = amount + }; + + var workflow = new OrderProcessingWorkflow(); + + try + { + await workflow.ExecuteAsync(ctx); + } + catch (Exception) + { + // Exception was logged by OnError hook + } + + return ctx.Log; + } + + public static async Task> RunWithInvalidAmountAsync() + { + var ctx = new OrderContext + { + OrderId = "ORD-INVALID", + Customer = "Jane Smith", + Amount = -50.00m // Invalid amount will cause failure + }; + + var workflow = new OrderProcessingWorkflow(); + + try + { + await workflow.ExecuteAsync(ctx); + } + catch (Exception) + { + // Exception was logged by OnError hook + } + + return ctx.Log; + } + + public static async Task> RunWithCancellationAsync(CancellationToken ct) + { + var ctx = new OrderContext + { + OrderId = "ORD-CANCEL", + Customer = "Bob Johnson", + Amount = 150.00m + }; + + var workflow = new OrderProcessingWorkflow(); + + try + { + await workflow.ExecuteAsync(ctx, ct); + } + catch (OperationCanceledException) + { + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Order processing was cancelled"); + } + catch (Exception) + { + // Exception was logged by OnError hook + } + + return ctx.Log; + } +} diff --git a/test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/ImportWorkflowDemoTests.cs b/test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/ImportWorkflowDemoTests.cs new file mode 100644 index 0000000..6f08e46 --- /dev/null +++ b/test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/ImportWorkflowDemoTests.cs @@ -0,0 +1,63 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.TemplateMethodGeneratorDemo; + +[Feature("Template Method Generator - Import Workflow")] +public sealed partial class ImportWorkflowDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Successful import runs all steps in order")] + [Fact] + public Task Successful_Import_Runs_All_Steps() + => Given("import workflow demo", () => PatternKit.Examples.TemplateMethodGeneratorDemo.ImportWorkflowDemo.Run()) + .When("executing the workflow", log => log) + .Then("starts with BeforeAll hook", log => log.Any(l => l.Contains("Starting import"))) + .And("loads data", log => log.Any(l => l.Contains("Loading data"))) + .And("validates data", log => log.Any(l => l.Contains("Validating data"))) + .And("transforms data", log => log.Any(l => l.Contains("Transforming data"))) + .And("persists data", log => log.Any(l => l.Contains("Persisting data"))) + .And("ends with AfterAll hook", log => log.Any(l => l.Contains("Import completed successfully"))) + .And("processes all 3 records", log => log.Any(l => l.Contains("3 records imported"))) + .AssertPassed(); + + [Scenario("Import validates data and logs errors")] + [Fact] + public Task Invalid_Data_Triggers_OnError_Hook() + => Given("import workflow with invalid data", + () => PatternKit.Examples.TemplateMethodGeneratorDemo.ImportWorkflowDemo.RunWithInvalidData()) + .When("executing the workflow", log => log) + .Then("loads data", log => log.Any(l => l.Contains("Loading data"))) + .And("attempts validation", log => log.Any(l => l.Contains("Validating data"))) + .And("finds invalid lines", log => log.Any(l => l.Contains("invalid lines"))) + .And("invokes OnError hook", log => log.Any(l => l.Contains("ERROR:"))) + .And("logs validation failure", log => log.Any(l => l.Contains("Import failed at validation stage"))) + .And("does not reach transform step", log => !log.Any(l => l.Contains("Transforming data"))) + .AssertPassed(); + + [Scenario("Steps execute in deterministic order")] + [Fact] + public Task Steps_Execute_In_Order() + => Given("import workflow demo", () => PatternKit.Examples.TemplateMethodGeneratorDemo.ImportWorkflowDemo.Run()) + .When("extracting step order from log", log => + { + var startIdx = log.FindIndex(l => l.Contains("Starting import")); + var loadIdx = log.FindIndex(l => l.Contains("Loading data")); + var validateIdx = log.FindIndex(l => l.Contains("Validating data")); + var transformIdx = log.FindIndex(l => l.Contains("Transforming data")); + var persistIdx = log.FindIndex(l => l.Contains("Persisting data")); + var completeIdx = log.FindIndex(l => l.Contains("Import completed")); + + return new[] { startIdx, loadIdx, validateIdx, transformIdx, persistIdx, completeIdx }; + }) + .Then("steps are in ascending order", indices => + { + for (int i = 1; i < indices.Length; i++) + { + if (indices[i] <= indices[i - 1]) + return false; + } + return true; + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/OrderProcessingDemoTests.cs b/test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/OrderProcessingDemoTests.cs new file mode 100644 index 0000000..d3fa017 --- /dev/null +++ b/test/PatternKit.Examples.Tests/TemplateMethodGeneratorDemo/OrderProcessingDemoTests.cs @@ -0,0 +1,77 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.TemplateMethodGeneratorDemo; + +[Feature("Template Method Generator - Async Order Processing")] +public sealed partial class OrderProcessingDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Successful order processing runs all async steps")] + [Fact] + public async Task Successful_Order_Processing() + { + var log = await PatternKit.Examples.TemplateMethodGeneratorDemo.OrderProcessingDemo.RunAsync(); + + Assert.True(log.Any(l => l.Contains("Starting order processing")), "Should start with BeforeAll hook"); + Assert.True(log.Any(l => l.Contains("Payment authorized")), "Should authorize payment"); + Assert.True(log.Any(l => l.Contains("Inventory reserved")), "Should reserve inventory"); + Assert.True(log.Any(l => l.Contains("Order confirmed")), "Should confirm order"); + Assert.True(log.Any(l => l.Contains("Notification sent")), "Should send notification"); + Assert.True(log.Any(l => l.Contains("Order processing completed successfully")), "Should end with AfterAll hook"); + Assert.True(log.Any(l => l.Contains("ready for fulfillment")), "Order should be ready for fulfillment"); + } + + [Scenario("Invalid payment amount triggers error handling")] + [Fact] + public async Task Invalid_Payment_Triggers_OnError() + { + var log = await PatternKit.Examples.TemplateMethodGeneratorDemo.OrderProcessingDemo.RunWithInvalidAmountAsync(); + + Assert.True(log.Any(l => l.Contains("Authorizing payment")), "Should attempt payment authorization"); + Assert.True(log.Any(l => l.Contains("ERROR: Invalid payment amount")), "Payment authorization should fail"); + Assert.True(log.Any(l => l.Contains("Order processing failed")), "Should invoke OnError hook"); + Assert.False(log.Any(l => l.Contains("Inventory reserved")), "Should not reserve inventory"); + Assert.False(log.Any(l => l.Contains("Order confirmed")), "Should not confirm order"); + } + + [Scenario("Cancellation is supported in async workflow")] + [Fact] + public async Task Cancellation_Support() + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); // Cancel quickly + + var log = await PatternKit.Examples.TemplateMethodGeneratorDemo.OrderProcessingDemo.RunWithCancellationAsync(cts.Token); + + // Either cancellation happened or workflow completed (race condition) + var result = log.Any(l => l.Contains("cancelled")) || log.Any(l => l.Contains("completed")); + Assert.True(result, "Should handle cancellation or complete"); + } + + [Scenario("Mixed sync and async steps work together")] + [Fact] + public async Task Mixed_Sync_Async_Steps() + { + var log = await PatternKit.Examples.TemplateMethodGeneratorDemo.OrderProcessingDemo.RunAsync(); + + var hasAsyncSteps = log.Any(l => l.Contains("Payment authorized")) && + log.Any(l => l.Contains("Notification sent")); + var hasSyncSteps = log.Any(l => l.Contains("Order confirmed")); + + Assert.True(hasAsyncSteps, "Should have async steps"); + Assert.True(hasSyncSteps, "Should have sync steps"); + } + + [Scenario("Error handling includes compensating actions")] + [Fact] + public async Task Error_Includes_Compensation() + { + var log = await PatternKit.Examples.TemplateMethodGeneratorDemo.OrderProcessingDemo.RunWithInvalidAmountAsync(); + + Assert.True(log.Any(l => l.Contains("Order processing failed")), "OnError hook should be invoked"); + // In this case, inventory wasn't reserved yet, so only payment rollback would be mentioned + var hasCompensationMention = log.Any(l => l.Contains("Rolling back")) || !log.Any(l => l.Contains("Inventory reserved")); + Assert.True(hasCompensationMention, "Should mention rollback actions or not have reserved inventory"); + } +} From 13d4976a1429032469ea313c1851d6d59dc589f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:35:52 +0000 Subject: [PATCH 5/8] Add comprehensive Template Method generator documentation Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/template-method-generator.md | 470 +++++++++++++++++++ docs/generators/toc.yml | 3 + 2 files changed, 473 insertions(+) create mode 100644 docs/generators/template-method-generator.md diff --git a/docs/generators/template-method-generator.md b/docs/generators/template-method-generator.md new file mode 100644 index 0000000..fc198fe --- /dev/null +++ b/docs/generators/template-method-generator.md @@ -0,0 +1,470 @@ +# Template Method Pattern Generator + +The Template Method Pattern Generator automatically creates execution orchestration methods for workflows defined with ordered steps and lifecycle hooks. It eliminates boilerplate code for sequential processing pipelines while providing deterministic execution, error handling, and async/await support. + +## Overview + +The generator produces: + +- **Execute method** for synchronous workflows +- **ExecuteAsync method** for asynchronous workflows with ValueTask and CancellationToken support +- **Deterministic step ordering** based on explicit Order values +- **Lifecycle hooks** (BeforeAll, AfterAll, OnError) +- **Error handling** with configurable policies +- **Zero runtime overhead** through source generation + +## Quick Start + +### 1. Define Your Workflow Host + +Mark your workflow class with `[Template]` and define steps: + +```csharp +using PatternKit.Generators.Template; + +public class ImportContext +{ + public List ProcessedItems { get; } = new(); +} + +[Template] +public partial class ImportWorkflow +{ + [TemplateStep(0)] + private void Load(ImportContext ctx) + { + // Load data + } + + [TemplateStep(1)] + private void Validate(ImportContext ctx) + { + // Validate data + } + + [TemplateStep(2)] + private void Transform(ImportContext ctx) + { + // Transform data + } + + [TemplateStep(3)] + private void Persist(ImportContext ctx) + { + // Persist results + } +} +``` + +### 2. Build Your Project + +The generator runs during compilation and produces an `Execute` method: + +```csharp +var ctx = new ImportContext(); +var workflow = new ImportWorkflow(); +workflow.Execute(ctx); // Runs all steps in order +``` + +### 3. Generated Code + +```csharp +partial class ImportWorkflow +{ + public void Execute(ImportContext ctx) + { + Load(ctx); + Validate(ctx); + Transform(ctx); + Persist(ctx); + } +} +``` + +## Core Concepts + +### Steps + +Steps define the sequence of operations in your workflow: + +```csharp +[TemplateStep(order, Name = "StepName", Optional = false)] +private void MethodName(ContextType ctx) { } +``` + +**Properties:** +- `order` (required): Execution order (ascending). Must be unique. +- `Name` (optional): Human-readable name for diagnostics +- `Optional` (optional): Mark step as optional for error handling + +**Requirements:** +- Must return `void` or `ValueTask` +- Must accept at least one parameter (the context) +- Async steps should accept `CancellationToken` as second parameter + +### Hooks + +Hooks provide lifecycle extension points: + +```csharp +[TemplateHook(HookPoint.BeforeAll)] +private void Setup(ImportContext ctx) { } + +[TemplateHook(HookPoint.AfterAll)] +private void Cleanup(ImportContext ctx) { } + +[TemplateHook(HookPoint.OnError)] +private void HandleError(ImportContext ctx, Exception ex) { } +``` + +**Hook Points:** +- `BeforeAll`: Invoked before any steps execute +- `AfterAll`: Invoked after all steps complete successfully +- `OnError`: Invoked when any step throws an exception + +### Async Workflows + +The generator automatically creates async methods when: +- Any step returns `ValueTask` +- Any step or hook accepts `CancellationToken` +- `GenerateAsync = true` is set on the `[Template]` attribute + +```csharp +[Template(GenerateAsync = true)] +public partial class AsyncWorkflow +{ + [TemplateStep(0)] + private async ValueTask LoadAsync(ImportContext ctx, CancellationToken ct) + { + await Task.Delay(100, ct); + } + + [TemplateStep(1)] + private void Process(ImportContext ctx) + { + // Synchronous step in async workflow + } +} + +// Usage +await workflow.ExecuteAsync(ctx, cancellationToken); +``` + +## Real-World Examples + +### Data Import Pipeline + +```csharp +public class ImportContext +{ + public string FilePath { get; set; } = ""; + public string[] RawData { get; set; } = Array.Empty(); + public List Records { get; set; } = new(); + public List Log { get; set; } = new(); +} + +[Template] +public partial class ImportWorkflow +{ + [TemplateHook(HookPoint.BeforeAll)] + private void OnStart(ImportContext ctx) + { + ctx.Log.Add($"Starting import from: {ctx.FilePath}"); + } + + [TemplateStep(0)] + private void LoadData(ImportContext ctx) + { + ctx.RawData = File.ReadAllLines(ctx.FilePath); + ctx.Log.Add($"Loaded {ctx.RawData.Length} lines"); + } + + [TemplateStep(1)] + private void ValidateData(ImportContext ctx) + { + var invalidLines = ctx.RawData + .Where(line => !line.Contains(":")) + .ToList(); + + if (invalidLines.Any()) + { + throw new InvalidOperationException( + $"Validation failed: {invalidLines.Count} invalid lines"); + } + + ctx.Log.Add("Validation passed"); + } + + [TemplateStep(2)] + private void TransformData(ImportContext ctx) + { + foreach (var line in ctx.RawData) + { + var parts = line.Split(':'); + ctx.Records.Add(new DataRecord(parts[0], parts[1])); + } + + ctx.Log.Add($"Transformed {ctx.Records.Count} records"); + } + + [TemplateStep(3)] + private void PersistData(ImportContext ctx) + { + // Save to database + ctx.Log.Add($"Persisted {ctx.Records.Count} records"); + } + + [TemplateHook(HookPoint.OnError)] + private void OnError(ImportContext ctx, Exception ex) + { + ctx.Log.Add($"ERROR: {ex.Message}"); + } + + [TemplateHook(HookPoint.AfterAll)] + private void OnComplete(ImportContext ctx) + { + ctx.Log.Add("Import completed successfully"); + } +} +``` + +### Async Order Processing + +```csharp +public class OrderContext +{ + public string OrderId { get; set; } = ""; + public decimal Amount { get; set; } + public bool PaymentAuthorized { get; set; } + public bool InventoryReserved { get; set; } +} + +[Template(GenerateAsync = true)] +public partial class OrderProcessingWorkflow +{ + [TemplateStep(0)] + private async ValueTask AuthorizePaymentAsync( + OrderContext ctx, CancellationToken ct) + { + // Call payment gateway + await Task.Delay(100, ct); + ctx.PaymentAuthorized = true; + } + + [TemplateStep(1)] + private async ValueTask ReserveInventoryAsync( + OrderContext ctx, CancellationToken ct) + { + // Reserve inventory + await Task.Delay(100, ct); + ctx.InventoryReserved = true; + } + + [TemplateStep(2)] + private void ConfirmOrder(OrderContext ctx) + { + // Synchronous confirmation + if (!ctx.PaymentAuthorized || !ctx.InventoryReserved) + { + throw new InvalidOperationException("Cannot confirm order"); + } + } + + [TemplateHook(HookPoint.OnError)] + private void HandleError(OrderContext ctx, Exception ex) + { + // Compensating actions + if (ctx.InventoryReserved) + { + // Release inventory + } + if (ctx.PaymentAuthorized) + { + // Refund payment + } + } +} + +// Usage +var ctx = new OrderContext { OrderId = "ORD-001", Amount = 99.99m }; +var workflow = new OrderProcessingWorkflow(); +await workflow.ExecuteAsync(ctx, cancellationToken); +``` + +## Configuration + +### Template Attribute Options + +```csharp +[Template( + ExecuteMethodName = "Process", // Default: "Execute" + ExecuteAsyncMethodName = "ProcessAsync", // Default: "ExecuteAsync" + GenerateAsync = true, // Force async generation + ForceAsync = false, // Generate async even without async steps + ErrorPolicy = TemplateErrorPolicy.Rethrow // Error handling policy +)] +public partial class CustomWorkflow { } +``` + +### Error Policies + +**Rethrow (Default):** +- OnError hook is invoked +- Exception is rethrown +- Workflow terminates + +**HandleAndContinue:** +- OnError hook is invoked +- Execution continues with next step +- Only allowed when remaining steps are all optional + +```csharp +[Template(ErrorPolicy = TemplateErrorPolicy.HandleAndContinue)] +public partial class ResilientWorkflow +{ + [TemplateStep(0)] + private void Step1(Context ctx) { } + + [TemplateStep(1, Optional = true)] + private void Step2(Context ctx) { } // Must be optional + + [TemplateStep(2, Optional = true)] + private void Step3(Context ctx) { } // Must be optional +} +``` + +## Diagnostics + +The generator provides actionable diagnostics: + +| Code | Message | Resolution | +|------|---------|------------| +| PKTMP001 | Type must be partial | Add `partial` keyword to type declaration | +| PKTMP002 | No template steps found | Add at least one `[TemplateStep]` method | +| PKTMP003 | Duplicate step order | Ensure each step has unique Order value | +| PKTMP004 | Invalid step signature | Step must return void/ValueTask and accept context | +| PKTMP005 | Invalid hook signature | Hook signature doesn't match requirements | +| PKTMP006 | Mixed sync/async signatures | Use consistent return types across steps | +| PKTMP007 | Missing CancellationToken | Add CancellationToken parameter to async steps | +| PKTMP008 | HandleAndContinue policy invalid | Make remaining steps optional or use Rethrow policy | + +## Supported Type Targets + +The generator works with: +- `partial class` +- `partial struct` +- `partial record class` +- `partial record struct` + +```csharp +[Template] +public partial class ClassWorkflow { } + +[Template] +public partial struct StructWorkflow { } + +[Template] +public partial record class RecordClassWorkflow { } + +[Template] +public partial record struct RecordStructWorkflow { } +``` + +## Best Practices + +### 1. Use Meaningful Context + +Create a dedicated context class that carries all state: + +```csharp +public class WorkflowContext +{ + public required string Input { get; init; } + public List Log { get; } = new(); + public Dictionary Metadata { get; } = new(); +} +``` + +### 2. Keep Steps Focused + +Each step should have a single responsibility: + +```csharp +[TemplateStep(0)] +private void ValidateInput(Context ctx) { /* Only validation */ } + +[TemplateStep(1)] +private void TransformData(Context ctx) { /* Only transformation */ } +``` + +### 3. Use Hooks for Cross-Cutting Concerns + +```csharp +[TemplateHook(HookPoint.BeforeAll)] +private void StartTimer(Context ctx) +{ + ctx.StartTime = DateTime.UtcNow; +} + +[TemplateHook(HookPoint.AfterAll)] +private void LogDuration(Context ctx) +{ + var duration = DateTime.UtcNow - ctx.StartTime; + ctx.Log.Add($"Duration: {duration.TotalMilliseconds}ms"); +} +``` + +### 4. Leverage Error Hooks for Cleanup + +```csharp +[TemplateHook(HookPoint.OnError)] +private void Cleanup(Context ctx, Exception ex) +{ + // Release resources + // Log error details + // Send notifications +} +``` + +## Migration from Runtime Template Method + +If you're using a runtime base class: + +**Before:** +```csharp +public class MyWorkflow : TemplateMethod +{ + protected override void OnBefore(Context ctx) { } + protected override Result Step(Context ctx) { } + protected override void OnAfter(Context ctx, Result result) { } +} +``` + +**After:** +```csharp +[Template] +public partial class MyWorkflow +{ + [TemplateHook(HookPoint.BeforeAll)] + private void OnBefore(Context ctx) { } + + [TemplateStep(0)] + private void Step(Context ctx) { } + + [TemplateHook(HookPoint.AfterAll)] + private void OnAfter(Context ctx) { } +} +``` + +**Benefits:** +- No inheritance required +- Multiple workflows per class +- Better testability +- Compile-time validation +- Zero runtime overhead + +## See Also + +- [API Reference](../api/PatternKit.Generators.Template.html) +- [Examples](examples.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 50f2159..ab90cdb 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -16,6 +16,9 @@ - name: Proxy href: proxy.md +- name: Template Method + href: template-method-generator.md + - name: Examples href: examples.md From 5771b3593d8e768f7ca9964d12aed2d4e8b82d38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:40:10 +0000 Subject: [PATCH 6/8] Fix AfterAll hook placement and diagnostic messages per code review Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../TemplateGenerator.cs | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/src/PatternKit.Generators/TemplateGenerator.cs b/src/PatternKit.Generators/TemplateGenerator.cs index b4a197e..b679f30 100644 --- a/src/PatternKit.Generators/TemplateGenerator.cs +++ b/src/PatternKit.Generators/TemplateGenerator.cs @@ -82,7 +82,7 @@ public sealed class TemplateGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor HandleAndContinuePolicyDescriptor = new( id: DiagIdHandleAndContinuePolicy, title: "HandleAndContinue policy not allowed with non-optional steps", - messageFormat: "ErrorPolicy=HandleAndContinue is not allowed when non-optional steps remain after step '{0}'. Make remaining steps optional or use ErrorPolicy=Rethrow.", + messageFormat: "ErrorPolicy=HandleAndContinue is not allowed when non-optional steps exist. Make all steps optional or use ErrorPolicy=Rethrow.", category: "PatternKit.Generators.Template", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -436,11 +436,9 @@ private bool ValidateHandleAndContinuePolicy( var nonOptionalSteps = steps.Where(s => !s.Optional).ToList(); if (nonOptionalSteps.Count > 0) { - var firstNonOptional = nonOptionalSteps.First(); context.ReportDiagnostic(Diagnostic.Create( HandleAndContinuePolicyDescriptor, - firstNonOptional.Method.Locations.FirstOrDefault(), - firstNonOptional.Name)); + Location.None)); return false; } @@ -514,6 +512,12 @@ private string GenerateTemplateMethod( sb.AppendLine($" {step.Method.Name}(ctx);"); } + // AfterAll hooks (inside try - only execute on success) + foreach (var hook in afterAllHooks) + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } + sb.AppendLine(" }"); sb.AppendLine(" catch (System.Exception ex)"); sb.AppendLine(" {"); @@ -538,12 +542,12 @@ private string GenerateTemplateMethod( { sb.AppendLine($" {step.Method.Name}(ctx);"); } - } - // AfterAll hooks - foreach (var hook in afterAllHooks) - { - sb.AppendLine($" {hook.Method.Name}(ctx);"); + // AfterAll hooks (no error handling) + foreach (var hook in afterAllHooks) + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } } sb.AppendLine(" }"); @@ -593,6 +597,22 @@ private string GenerateTemplateMethod( } } + // AfterAll hooks (inside try - only execute on success) + foreach (var hook in afterAllHooks) + { + var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + if (isAsync) + { + var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var args = hasCt ? "ctx, ct" : "ctx"; + sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); + } + else + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } + } + sb.AppendLine(" }"); sb.AppendLine(" catch (System.Exception ex)"); sb.AppendLine(" {"); @@ -637,21 +657,21 @@ private string GenerateTemplateMethod( sb.AppendLine($" {step.Method.Name}(ctx);"); } } - } - // AfterAll hooks - foreach (var hook in afterAllHooks) - { - var isAsync = hook.Method.ReturnType.Name == "ValueTask"; - if (isAsync) - { - var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); - var args = hasCt ? "ctx, ct" : "ctx"; - sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); - } - else + // AfterAll hooks (no error handling) + foreach (var hook in afterAllHooks) { - sb.AppendLine($" {hook.Method.Name}(ctx);"); + var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + if (isAsync) + { + var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var args = hasCt ? "ctx, ct" : "ctx"; + sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); + } + else + { + sb.AppendLine($" {hook.Method.Name}(ctx);"); + } } } From 699b33dc1b59bebac8c626ee4d1589a86a7845ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:31:35 +0000 Subject: [PATCH 7/8] Address PR review feedback: fix ValueTask validation, remove unused diagnostic, refactor loops, add tests Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/template-method-generator.md | 1 - .../ImportWorkflowDemo.cs | 30 ++-- .../TemplateGenerator.cs | 74 ++++---- .../TemplateGeneratorTests.cs | 167 +++++++++++++++++- 4 files changed, 220 insertions(+), 52 deletions(-) diff --git a/docs/generators/template-method-generator.md b/docs/generators/template-method-generator.md index fc198fe..c1e139f 100644 --- a/docs/generators/template-method-generator.md +++ b/docs/generators/template-method-generator.md @@ -344,7 +344,6 @@ The generator provides actionable diagnostics: | PKTMP003 | Duplicate step order | Ensure each step has unique Order value | | PKTMP004 | Invalid step signature | Step must return void/ValueTask and accept context | | PKTMP005 | Invalid hook signature | Hook signature doesn't match requirements | -| PKTMP006 | Mixed sync/async signatures | Use consistent return types across steps | | PKTMP007 | Missing CancellationToken | Add CancellationToken parameter to async steps | | PKTMP008 | HandleAndContinue policy invalid | Make remaining steps optional or use Rethrow policy | diff --git a/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs index 57fd66b..bc0a647 100644 --- a/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs +++ b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs @@ -44,20 +44,14 @@ private void LoadData(ImportContext ctx) ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Loading data..."); // Simulate loading from file - if (File.Exists(ctx.FilePath)) - { - ctx.RawData = File.ReadAllLines(ctx.FilePath); - } - else - { - // For demo purposes, create sample data - ctx.RawData = new[] + ctx.RawData = File.Exists(ctx.FilePath) + ? File.ReadAllLines(ctx.FilePath) + : new[] { "Name:Alice;Value:100", "Name:Bob;Value:200", "Name:Charlie;Value:300" }; - } ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Loaded {ctx.RawData.Length} lines"); } @@ -94,14 +88,16 @@ private void TransformData(ImportContext ctx) { ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Transforming data..."); - foreach (var line in ctx.RawData) - { - var parts = line.Split(';'); - var name = parts[0].Split(':')[1]; - var value = parts[1].Split(':')[1]; - - ctx.Records.Add(new DataRecord(name, value)); - } + var records = ctx.RawData + .Select(line => + { + var parts = line.Split(';'); + var name = parts[0].Split(':')[1]; + var value = parts[1].Split(':')[1]; + return new DataRecord(name, value); + }); + + ctx.Records.AddRange(records); ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Transformed {ctx.Records.Count} records"); } diff --git a/src/PatternKit.Generators/TemplateGenerator.cs b/src/PatternKit.Generators/TemplateGenerator.cs index b679f30..8043c48 100644 --- a/src/PatternKit.Generators/TemplateGenerator.cs +++ b/src/PatternKit.Generators/TemplateGenerator.cs @@ -19,7 +19,6 @@ public sealed class TemplateGenerator : IIncrementalGenerator private const string DiagIdDuplicateOrder = "PKTMP003"; private const string DiagIdInvalidStepSignature = "PKTMP004"; private const string DiagIdInvalidHookSignature = "PKTMP005"; - private const string DiagIdMixedAsyncSignatures = "PKTMP006"; private const string DiagIdMissingCancellationToken = "PKTMP007"; private const string DiagIdHandleAndContinuePolicy = "PKTMP008"; @@ -63,14 +62,6 @@ public sealed class TemplateGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); - private static readonly DiagnosticDescriptor MixedAsyncSignaturesDescriptor = new( - id: DiagIdMixedAsyncSignatures, - title: "Mixed sync/async signatures detected", - messageFormat: "Type '{0}' has mixed synchronous and asynchronous steps/hooks. All steps must consistently return void or ValueTask.", - category: "PatternKit.Generators.Template", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true); - private static readonly DiagnosticDescriptor MissingCancellationTokenDescriptor = new( id: DiagIdMissingCancellationToken, title: "CancellationToken parameter required for async step", @@ -211,11 +202,8 @@ private ImmutableArray CollectSteps(INamedTypeSymbol typeSymbol, Sour { var builder = ImmutableArray.CreateBuilder(); - foreach (var member in typeSymbol.GetMembers()) + foreach (var method in typeSymbol.GetMembers().OfType()) { - if (member is not IMethodSymbol method) - continue; - var stepAttr = method.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Template.TemplateStepAttribute"); @@ -253,11 +241,8 @@ private ImmutableArray CollectHooks(INamedTypeSymbol typeSymbol, Sour { var builder = ImmutableArray.CreateBuilder(); - foreach (var member in typeSymbol.GetMembers()) + foreach (var method in typeSymbol.GetMembers().OfType()) { - if (member is not IMethodSymbol method) - continue; - var hookAttr = method.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Template.TemplateHookAttribute"); @@ -323,10 +308,13 @@ private bool ValidateSignatures( private bool ValidateStepSignature(IMethodSymbol method, SourceProductionContext context) { - // Step must return void or ValueTask + // Step must return void or non-generic ValueTask var returnsVoid = method.ReturnsVoid; - var returnsValueTask = method.ReturnType.Name == "ValueTask" && - method.ReturnType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; + var returnType = method.ReturnType; + var returnsValueTask = returnType is INamedTypeSymbol namedType && + namedType.Name == "ValueTask" && + namedType.Arity == 0 && + namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; if (!returnsVoid && !returnsValueTask) { @@ -361,10 +349,13 @@ private bool ValidateStepSignature(IMethodSymbol method, SourceProductionContext private bool ValidateHookSignature(IMethodSymbol method, int hookPoint, SourceProductionContext context) { - // Hook must return void or ValueTask + // Hook must return void or non-generic ValueTask var returnsVoid = method.ReturnsVoid; - var returnsValueTask = method.ReturnType.Name == "ValueTask" && - method.ReturnType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; + var returnType = method.ReturnType; + var returnsValueTask = returnType is INamedTypeSymbol namedType && + namedType.Name == "ValueTask" && + namedType.Arity == 0 && + namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; if (!returnsVoid && !returnsValueTask) { @@ -408,19 +399,29 @@ private bool ValidateHookSignature(IMethodSymbol method, int hookPoint, SourcePr private bool DetermineIfAsync(ImmutableArray steps, ImmutableArray hooks) { - // Check if any step or hook returns ValueTask or accepts CancellationToken + // Check if any step or hook returns non-generic ValueTask or accepts CancellationToken foreach (var step in steps) { - if (step.Method.ReturnType.Name == "ValueTask") + var returnType = step.Method.ReturnType; + if (returnType is INamedTypeSymbol namedType && + namedType.Name == "ValueTask" && + namedType.Arity == 0 && + namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks") return true; + if (step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken")) return true; } foreach (var hook in hooks) { - if (hook.Method.ReturnType.Name == "ValueTask") + var returnType = hook.Method.ReturnType; + if (returnType is INamedTypeSymbol namedType && + namedType.Name == "ValueTask" && + namedType.Arity == 0 && + namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks") return true; + if (hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken")) return true; } @@ -562,7 +563,7 @@ private string GenerateTemplateMethod( // BeforeAll hooks foreach (var hook in beforeAllHooks) { - var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); @@ -584,7 +585,7 @@ private string GenerateTemplateMethod( // Steps foreach (var step in sortedSteps) { - var isAsync = step.Method.ReturnType.Name == "ValueTask"; + var isAsync = IsNonGenericValueTask(step.Method.ReturnType); if (isAsync) { var hasCt = step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); @@ -600,7 +601,7 @@ private string GenerateTemplateMethod( // AfterAll hooks (inside try - only execute on success) foreach (var hook in afterAllHooks) { - var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); @@ -620,7 +621,7 @@ private string GenerateTemplateMethod( // OnError hooks foreach (var hook in onErrorHooks) { - var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); @@ -645,7 +646,7 @@ private string GenerateTemplateMethod( // Steps without try-catch foreach (var step in sortedSteps) { - var isAsync = step.Method.ReturnType.Name == "ValueTask"; + var isAsync = IsNonGenericValueTask(step.Method.ReturnType); if (isAsync) { var hasCt = step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); @@ -661,7 +662,7 @@ private string GenerateTemplateMethod( // AfterAll hooks (no error handling) foreach (var hook in afterAllHooks) { - var isAsync = hook.Method.ReturnType.Name == "ValueTask"; + var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); @@ -683,6 +684,15 @@ private string GenerateTemplateMethod( return sb.ToString(); } + // Helper method to check if a return type is non-generic ValueTask + private static bool IsNonGenericValueTask(ITypeSymbol returnType) + { + return returnType is INamedTypeSymbol namedType && + namedType.Name == "ValueTask" && + namedType.Arity == 0 && + namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; + } + // Helper classes private class TemplateConfig { diff --git a/test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs b/test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs index 0b658e8..b265063 100644 --- a/test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/TemplateGeneratorTests.cs @@ -1,6 +1,4 @@ using Microsoft.CodeAnalysis; -using PatternKit.Common; -using PatternKit.Creational.Builder; namespace PatternKit.Generators.Tests; @@ -702,6 +700,171 @@ private ValueTask ValidateAsync(ImportContext ctx) Assert.Contains(diagnostics, d => d.Id == "PKTMP007"); } + [Fact] + public void Reports_Error_When_Invalid_Hook_Signature_No_Context() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateHook(HookPoint.BeforeAll)] + private void OnStart() { } // Missing context parameter + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_Invalid_Hook_Signature_No_Context)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP005"); + } + + [Fact] + public void Reports_Error_When_OnError_Hook_Missing_Exception_Parameter() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateHook(HookPoint.OnError)] + private void OnError(ImportContext ctx) { } // Missing Exception parameter + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_OnError_Hook_Missing_Exception_Parameter)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP005"); + } + + [Fact] + public void Reports_Error_When_Hook_Returns_Invalid_Type() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } + + [TemplateHook(HookPoint.BeforeAll)] + private int OnStart(ImportContext ctx) { return 0; } // Invalid return type + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_Hook_Returns_Invalid_Type)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP005"); + } + + [Fact] + public void Reports_Error_When_HandleAndContinue_With_NonOptional_Steps() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template(ErrorPolicy = TemplateErrorPolicy.HandleAndContinue)] + public partial class ImportWorkflow + { + [TemplateStep(0)] + private void Validate(ImportContext ctx) { } // Non-optional step + + [TemplateStep(1)] + private void Transform(ImportContext ctx) { } // Non-optional step + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Reports_Error_When_HandleAndContinue_With_NonOptional_Steps)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKTMP008"); + } + + [Fact] + public void Allows_HandleAndContinue_With_All_Optional_Steps() + { + var source = """ + using PatternKit.Generators.Template; + + namespace PatternKit.Examples; + + public class ImportContext { } + + [Template(ErrorPolicy = TemplateErrorPolicy.HandleAndContinue)] + public partial class ImportWorkflow + { + [TemplateStep(0, Optional = true)] + private void Validate(ImportContext ctx) { } + + [TemplateStep(1, Optional = true)] + private void Transform(ImportContext ctx) { } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(Allows_HandleAndContinue_With_All_Optional_Steps)); + + var gen = new TemplateGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + // Should not have PKTMP008 diagnostic + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.DoesNotContain(diagnostics, d => d.Id == "PKTMP008"); + + // Should compile successfully + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + #endregion #region Custom Method Names From 80c63701f9afc994c7fb49d23aefd93a7ded79f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 04:34:15 +0000 Subject: [PATCH 8/8] Address second round of PR feedback: fix CancellationToken detection, update docs, refactor validation loops Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/template-method-generator.md | 11 ++--- .../ImportWorkflowDemo.cs | 2 +- .../TemplateGenerator.cs | 40 +++++++++---------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/docs/generators/template-method-generator.md b/docs/generators/template-method-generator.md index c1e139f..95c82fc 100644 --- a/docs/generators/template-method-generator.md +++ b/docs/generators/template-method-generator.md @@ -315,15 +315,16 @@ public partial class CustomWorkflow { } **HandleAndContinue:** - OnError hook is invoked -- Execution continues with next step -- Only allowed when remaining steps are all optional +- Exception is suppressed (not rethrown) +- Workflow terminates +- Only allowed when all steps are optional ```csharp [Template(ErrorPolicy = TemplateErrorPolicy.HandleAndContinue)] public partial class ResilientWorkflow { - [TemplateStep(0)] - private void Step1(Context ctx) { } + [TemplateStep(0, Optional = true)] + private void Step1(Context ctx) { } // Must be optional [TemplateStep(1, Optional = true)] private void Step2(Context ctx) { } // Must be optional @@ -345,7 +346,7 @@ The generator provides actionable diagnostics: | PKTMP004 | Invalid step signature | Step must return void/ValueTask and accept context | | PKTMP005 | Invalid hook signature | Hook signature doesn't match requirements | | PKTMP007 | Missing CancellationToken | Add CancellationToken parameter to async steps | -| PKTMP008 | HandleAndContinue policy invalid | Make remaining steps optional or use Rethrow policy | +| PKTMP008 | HandleAndContinue policy invalid | Make all steps optional or use Rethrow policy | ## Supported Type Targets diff --git a/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs index bc0a647..04a8d0d 100644 --- a/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs +++ b/src/PatternKit.Examples/TemplateMethodGeneratorDemo/ImportWorkflowDemo.cs @@ -126,7 +126,7 @@ private void PersistData(ImportContext ctx) private void OnError(ImportContext ctx, Exception ex) { ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] ERROR: {ex.Message}"); - ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Import failed at validation stage"); + ctx.Log.Add($"[{DateTime.UtcNow:HH:mm:ss}] Import failed"); } /// diff --git a/src/PatternKit.Generators/TemplateGenerator.cs b/src/PatternKit.Generators/TemplateGenerator.cs index 8043c48..1a0b63c 100644 --- a/src/PatternKit.Generators/TemplateGenerator.cs +++ b/src/PatternKit.Generators/TemplateGenerator.cs @@ -289,19 +289,13 @@ private bool ValidateSignatures( INamedTypeSymbol typeSymbol, SourceProductionContext context) { - // Validate step signatures - foreach (var step in steps) - { - if (!ValidateStepSignature(step.Method, context)) - return false; - } + // Validate step signatures - return false if any validation fails + if (steps.Any(step => !ValidateStepSignature(step.Method, context))) + return false; - // Validate hook signatures - foreach (var hook in hooks) - { - if (!ValidateHookSignature(hook.Method, hook.HookPoint, context)) - return false; - } + // Validate hook signatures - return false if any validation fails + if (hooks.Any(hook => !ValidateHookSignature(hook.Method, hook.HookPoint, context))) + return false; return true; } @@ -409,7 +403,7 @@ private bool DetermineIfAsync(ImmutableArray steps, ImmutableArray p.Type.Name == "CancellationToken")) + if (step.Method.Parameters.Any(p => p.Type.ToDisplayString() == "System.Threading.CancellationToken")) return true; } @@ -422,7 +416,7 @@ private bool DetermineIfAsync(ImmutableArray steps, ImmutableArray p.Type.Name == "CancellationToken")) + if (hook.Method.Parameters.Any(p => p.Type.ToDisplayString() == "System.Threading.CancellationToken")) return true; } @@ -566,7 +560,7 @@ private string GenerateTemplateMethod( var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { - var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var hasCt = hook.Method.Parameters.Any(IsCancellationToken); var args = hasCt ? "ctx, ct" : "ctx"; sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); } @@ -588,7 +582,7 @@ private string GenerateTemplateMethod( var isAsync = IsNonGenericValueTask(step.Method.ReturnType); if (isAsync) { - var hasCt = step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var hasCt = step.Method.Parameters.Any(IsCancellationToken); var args = hasCt ? "ctx, ct" : "ctx"; sb.AppendLine($" await {step.Method.Name}({args}).ConfigureAwait(false);"); } @@ -604,7 +598,7 @@ private string GenerateTemplateMethod( var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { - var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var hasCt = hook.Method.Parameters.Any(IsCancellationToken); var args = hasCt ? "ctx, ct" : "ctx"; sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); } @@ -624,7 +618,7 @@ private string GenerateTemplateMethod( var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { - var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var hasCt = hook.Method.Parameters.Any(IsCancellationToken); var args = hasCt ? "ctx, ex, ct" : "ctx, ex"; sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); } @@ -649,7 +643,7 @@ private string GenerateTemplateMethod( var isAsync = IsNonGenericValueTask(step.Method.ReturnType); if (isAsync) { - var hasCt = step.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var hasCt = step.Method.Parameters.Any(IsCancellationToken); var args = hasCt ? "ctx, ct" : "ctx"; sb.AppendLine($" await {step.Method.Name}({args}).ConfigureAwait(false);"); } @@ -665,7 +659,7 @@ private string GenerateTemplateMethod( var isAsync = IsNonGenericValueTask(hook.Method.ReturnType); if (isAsync) { - var hasCt = hook.Method.Parameters.Any(p => p.Type.Name == "CancellationToken"); + var hasCt = hook.Method.Parameters.Any(IsCancellationToken); var args = hasCt ? "ctx, ct" : "ctx"; sb.AppendLine($" await {hook.Method.Name}({args}).ConfigureAwait(false);"); } @@ -693,6 +687,12 @@ private static bool IsNonGenericValueTask(ITypeSymbol returnType) namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks"; } + // Helper method to check if a parameter is a CancellationToken + private static bool IsCancellationToken(IParameterSymbol parameter) + { + return parameter.Type.ToDisplayString() == "System.Threading.CancellationToken"; + } + // Helper classes private class TemplateConfig {