From 30069b760a32aacd297232582e2b161c3957c5d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:16:10 +0000 Subject: [PATCH 1/4] Add WorkGraphGenerator for Keys and Create facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit {Holder}WorkStepKeys and {Holder}WorkGraph.Create from [WorkGraph]/[WorkStep] catalogs, sharing WorkGraphBuilder execution and reporting DP087–DP092 at compile time. Co-authored-by: 落笔wys --- .../Generators/TrackingNames.cs | 7 + .../Generators/WorkGraphGenerator.cs | 575 ++++++++++++++++++ .../Generators/WorkGraphModels.cs | 132 ++++ .../Syntax/WorkGraphSyntaxFactory.cs | 159 +++++ 4 files changed, 873 insertions(+) create mode 100644 DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs create mode 100644 DesignPatterns.SourceGenerators/Generators/WorkGraphModels.cs create mode 100644 DesignPatterns.SourceGenerators/Syntax/WorkGraphSyntaxFactory.cs diff --git a/DesignPatterns.SourceGenerators/Generators/TrackingNames.cs b/DesignPatterns.SourceGenerators/Generators/TrackingNames.cs index dbe9d7d..97a3bde 100644 --- a/DesignPatterns.SourceGenerators/Generators/TrackingNames.cs +++ b/DesignPatterns.SourceGenerators/Generators/TrackingNames.cs @@ -74,4 +74,11 @@ internal static class TrackingNames public const string CommandPipelineBehaviorCombine = nameof(CommandPipelineBehaviorCombine); public const string CommandHandlerPipelineCombine = nameof(CommandHandlerPipelineCombine); + + // WorkGraphGenerator + public const string WorkGraphHolderNonGenericTransform = nameof(WorkGraphHolderNonGenericTransform); + public const string WorkGraphHolderGenericTransform = nameof(WorkGraphHolderGenericTransform); + public const string WorkStepTransform = nameof(WorkStepTransform); + public const string WorkGraphHolderCombine = nameof(WorkGraphHolderCombine); + public const string WorkGraphCombine = nameof(WorkGraphCombine); } diff --git a/DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs b/DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs new file mode 100644 index 0000000..a11050b --- /dev/null +++ b/DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using DesignPatterns.Diagnostics; +using DesignPatterns.SourceGenerators.Syntax; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace DesignPatterns.SourceGenerators.Generators; + +/// +/// Generates {Holder}WorkStepKeys and {Holder}WorkGraph.Create for +/// [WorkGraph] / [WorkStep] catalogs, reporting DP087–DP092. +/// +[Generator] +public sealed class WorkGraphGenerator : IIncrementalGenerator +{ + /// Metadata name for non-generic WorkGraphAttribute. + public const string WorkGraphMetadataName = "DesignPatterns.Behavioral.WorkGraphAttribute"; + + /// Metadata name for generic WorkGraphAttribute<TContext>. + public const string WorkGraphGenericMetadataName = "DesignPatterns.Behavioral.WorkGraphAttribute`1"; + + /// Metadata name for WorkStepAttribute. + public const string WorkStepMetadataName = "DesignPatterns.Behavioral.WorkStepAttribute"; + + private const string IWorkStepMetadataName = "DesignPatterns.Behavioral.IWorkStep`1"; + + private static readonly SymbolDisplayFormat FullyQualifiedFormat = + SymbolDisplayFormat.FullyQualifiedFormat + .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Included) + .WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var holdersNonGeneric = context.SyntaxProvider.ForAttributeWithMetadataName( + WorkGraphMetadataName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => TransformHolder(ctx, isGenericAttribute: false)) + .WithTrackingName(TrackingNames.WorkGraphHolderNonGenericTransform); + + var holdersGeneric = context.SyntaxProvider.ForAttributeWithMetadataName( + WorkGraphGenericMetadataName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => TransformHolder(ctx, isGenericAttribute: true)) + .WithTrackingName(TrackingNames.WorkGraphHolderGenericTransform); + + var steps = context.SyntaxProvider.ForAttributeWithMetadataName( + WorkStepMetadataName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => TransformSteps(ctx)) + .WithTrackingName(TrackingNames.WorkStepTransform); + + var holders = holdersNonGeneric.Collect() + .Combine(holdersGeneric.Collect()) + .WithTrackingName(TrackingNames.WorkGraphHolderCombine); + + context.RegisterSourceOutput( + holders.Combine(steps.Collect()).WithTrackingName(TrackingNames.WorkGraphCombine), + static (spc, source) => Execute( + spc, + source.Left.Left, + source.Left.Right, + source.Right)); + } + + private static Result TransformHolder( + GeneratorAttributeSyntaxContext context, + bool isGenericAttribute) + { + if (context.TargetSymbol is not INamedTypeSymbol holder) + { + return Result.Empty; + } + + if (context.Attributes.IsDefaultOrEmpty) + { + return Result.Empty; + } + + var attribute = context.Attributes[0]; + INamedTypeSymbol? contextType = null; + if (isGenericAttribute) + { + if (attribute.AttributeClass is { IsGenericType: true, TypeArguments.Length: > 0 }) + { + contextType = attribute.AttributeClass.TypeArguments[0] as INamedTypeSymbol; + } + } + else if (attribute.ConstructorArguments.Length > 0) + { + contextType = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + } + + if (contextType is null || contextType.TypeKind == TypeKind.Error) + { + return Result.Empty; + } + + var info = new WorkGraphHolderInfo( + holder.Name, + holder.ContainingNamespace.IsGlobalNamespace + ? null + : holder.ContainingNamespace.ToDisplayString(), + holder.ToDisplayString(FullyQualifiedFormat), + contextType.ToDisplayString(FullyQualifiedFormat), + contextType.Name, + new LocationInfo(context.TargetNode.GetLocation())); + + return Result.Success(info); + } + + private static EquatableArray TransformSteps(GeneratorAttributeSyntaxContext context) + { + var results = new List(); + if (context.TargetSymbol is not INamedTypeSymbol implementation) + { + return new EquatableArray(results.ToArray()); + } + + var compilation = context.SemanticModel.Compilation; + var iWorkStep = compilation.GetTypeByMetadataName(IWorkStepMetadataName); + + foreach (var attribute in context.Attributes) + { + if (attribute.ConstructorArguments.Length == 0) + { + continue; + } + + var graph = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + if (graph is null || graph.TypeKind == TypeKind.Error) + { + continue; + } + + string? id = null; + var dependsOn = Array.Empty(); + foreach (var named in attribute.NamedArguments) + { + if (named.Key == "Id" && named.Value.Value is string idValue) + { + id = idValue; + } + else if (named.Key == "DependsOn" && !named.Value.IsNull) + { + dependsOn = ExtractDependsOn(named.Value); + } + } + + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + // Resolve holder context type for contract check (generic or non-generic WorkGraph). + var holderContext = TryGetHolderContextType(graph); + var implements = holderContext is not null + && iWorkStep is not null + && ImplementsWorkStep(implementation, iWorkStep, holderContext); + + results.Add(new WorkStepInfo( + id!, + WorkGraphSyntaxFactory.ToConstantName(id!), + implementation.ToDisplayString(FullyQualifiedFormat), + implementation.Name, + graph.ToDisplayString(FullyQualifiedFormat), + new EquatableArray(dependsOn), + implements, + new LocationInfo(context.TargetNode.GetLocation()))); + } + + return new EquatableArray(results.ToArray()); + } + + private static string[] ExtractDependsOn(TypedConstant value) + { + if (value.Kind != TypedConstantKind.Array || value.Values.IsDefaultOrEmpty) + { + return Array.Empty(); + } + + var list = new List(value.Values.Length); + foreach (var element in value.Values) + { + if (element.Value is string s && !string.IsNullOrWhiteSpace(s)) + { + list.Add(s); + } + else if (element.Value is string) + { + // Preserve blank entries so unknown/self validation can still see them as errors? + // Spec: null/whitespace DependsOn is invalid at runtime. Report as unknown with empty? + // Skip blanks here; blank ids are not registered step ids. + } + } + + return list.ToArray(); + } + + private static INamedTypeSymbol? TryGetHolderContextType(INamedTypeSymbol holder) + { + foreach (var attribute in holder.GetAttributes()) + { + var attrClass = attribute.AttributeClass; + if (attrClass is null) + { + continue; + } + + if (!string.Equals(attrClass.Name, "WorkGraphAttribute", StringComparison.Ordinal) + || !string.Equals( + attrClass.ContainingNamespace?.ToDisplayString(), + "DesignPatterns.Behavioral", + StringComparison.Ordinal)) + { + continue; + } + + if (attrClass.IsGenericType && attrClass.TypeArguments.Length == 1) + { + return attrClass.TypeArguments[0] as INamedTypeSymbol; + } + + if (!attrClass.IsGenericType && attribute.ConstructorArguments.Length > 0) + { + return attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + } + } + + return null; + } + + private static bool ImplementsWorkStep( + INamedTypeSymbol implementation, + INamedTypeSymbol iWorkStep, + INamedTypeSymbol contextType) + { + foreach (var iface in implementation.AllInterfaces) + { + if (iface.OriginalDefinition.Equals(iWorkStep, SymbolEqualityComparer.Default) + && iface.TypeArguments.Length == 1 + && SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], contextType)) + { + return true; + } + } + + return false; + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray> holdersNonGeneric, + ImmutableArray> holdersGeneric, + ImmutableArray> stepGroups) + { + var holders = ResultExtensions.ReportAndCollect(context, holdersNonGeneric) + .Concat(ResultExtensions.ReportAndCollect(context, holdersGeneric)) + .GroupBy(static h => h.HolderFullyQualifiedDisplayString, StringComparer.Ordinal) + .Select(static g => g.First()) + .ToList(); + + if (holders.Count == 0) + { + return; + } + + var allSteps = new List(); + foreach (var group in stepGroups) + { + foreach (var step in group) + { + allSteps.Add(step); + } + } + + var stepsByHolder = allSteps + .GroupBy(static s => s.HolderFullyQualifiedDisplayString, StringComparer.Ordinal) + .ToDictionary(static g => g.Key, static g => g.ToList(), StringComparer.Ordinal); + + foreach (var holder in holders.OrderBy(static h => h.HolderFullyQualifiedDisplayString, StringComparer.Ordinal)) + { + if (!stepsByHolder.TryGetValue(holder.HolderFullyQualifiedDisplayString, out var steps) + || steps.Count == 0) + { + // Empty catalog: no Keys/Create emission (runtime Build still rejects empty). + continue; + } + + if (!TryBuildEmitModel(context, holder, steps, out var model)) + { + continue; + } + + var compilationUnit = WorkGraphSyntaxFactory.CreateCompilationUnit(model); + var hintBase = HintNameHelper.FromString(holder.HolderFullyQualifiedDisplayString); + context.AddSource( + hintBase + ".WorkGraph.g.cs", + SourceText.From(compilationUnit.ToFullString(), Encoding.UTF8)); + } + } + + private static bool TryBuildEmitModel( + SourceProductionContext context, + WorkGraphHolderInfo holder, + List steps, + out WorkGraphEmitModel model) + { + model = null!; + var hasError = false; + + foreach (var step in steps) + { + if (!step.ImplementsContract) + { + context.ReportDiagnostic(Diagnostic.Create( + DesignPatternsDiagnosticDescriptors.WorkGraphContractMismatch, + step.Location.ToLocation(), + step.ImplementationName, + holder.HolderName, + holder.ContextName)); + hasError = true; + } + } + + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var step in steps) + { + if (byId.ContainsKey(step.StepId)) + { + context.ReportDiagnostic(Diagnostic.Create( + DesignPatternsDiagnosticDescriptors.WorkGraphDuplicateStepId, + step.Location.ToLocation(), + step.StepId, + holder.HolderName)); + hasError = true; + continue; + } + + byId[step.StepId] = step; + } + + foreach (var step in steps) + { + foreach (var dependency in step.DependsOn) + { + if (string.Equals(dependency, step.StepId, StringComparison.Ordinal)) + { + context.ReportDiagnostic(Diagnostic.Create( + DesignPatternsDiagnosticDescriptors.WorkGraphSelfDependency, + step.Location.ToLocation(), + step.StepId, + holder.HolderName)); + hasError = true; + } + else if (!byId.ContainsKey(dependency)) + { + context.ReportDiagnostic(Diagnostic.Create( + DesignPatternsDiagnosticDescriptors.WorkGraphUnknownDependency, + step.Location.ToLocation(), + step.StepId, + holder.HolderName, + dependency)); + hasError = true; + } + } + } + + // Cycle detection among known, non-self edges. + var cyclicIds = FindCyclicStepIds(byId); + if (cyclicIds.Count > 0) + { + var joined = string.Join(", ", cyclicIds.OrderBy(static id => id, StringComparer.Ordinal)); + context.ReportDiagnostic(Diagnostic.Create( + DesignPatternsDiagnosticDescriptors.WorkGraphCycle, + holder.Location.ToLocation(), + holder.HolderName, + joined)); + hasError = true; + } + + // Unreachable: not visited from any root (empty DependsOn) via successor edges. + var unreachable = FindUnreachableStepIds(byId); + foreach (var id in unreachable.OrderBy(static x => x, StringComparer.Ordinal)) + { + if (byId.TryGetValue(id, out var step)) + { + context.ReportDiagnostic(Diagnostic.Create( + DesignPatternsDiagnosticDescriptors.WorkGraphUnreachableStep, + step.Location.ToLocation(), + step.StepId, + holder.HolderName)); + } + } + + // Warnings alone do not block emission; errors do. + // Unreachable with a cycle is always accompanied by DP087 (error), so emission is blocked. + // Pure unreachable without cycle cannot occur in a valid DAG — keep warning-only path emit-safe. + if (hasError) + { + return false; + } + + var constantById = byId.ToDictionary( + static pair => pair.Key, + static pair => pair.Value.ConstantName, + StringComparer.Ordinal); + + var emitSteps = byId.Values + .OrderBy(static s => s.StepId, StringComparer.Ordinal) + .Select(step => + { + var depConstants = step.DependsOn + .Where(dep => constantById.ContainsKey(dep)) + .Select(dep => constantById[dep]) + .ToArray(); + return new WorkStepEmitModel( + step.StepId, + step.ConstantName, + new EquatableArray(depConstants)); + }) + .ToArray(); + + model = new WorkGraphEmitModel( + holder.HolderName, + holder.NamespaceName, + holder.HolderFullyQualifiedDisplayString, + holder.ContextFullyQualifiedDisplayString, + new EquatableArray(emitSteps)); + return true; + } + + private static HashSet FindCyclicStepIds(Dictionary byId) + { + var remainingIndeegree = new Dictionary(byId.Count, StringComparer.Ordinal); + var successors = new Dictionary>(byId.Count, StringComparer.Ordinal); + + foreach (var id in byId.Keys) + { + remainingIndeegree[id] = 0; + successors[id] = new List(); + } + + foreach (var registration in byId.Values) + { + foreach (var dependency in registration.DependsOn) + { + if (string.Equals(dependency, registration.StepId, StringComparison.Ordinal)) + { + continue; + } + + if (!byId.ContainsKey(dependency)) + { + continue; + } + + remainingIndeegree[registration.StepId]++; + successors[dependency].Add(registration.StepId); + } + } + + var ready = new Queue(); + foreach (var pair in remainingIndeegree) + { + if (pair.Value == 0) + { + ready.Enqueue(pair.Key); + } + } + + var scheduled = 0; + while (ready.Count > 0) + { + var id = ready.Dequeue(); + scheduled++; + foreach (var successor in successors[id]) + { + remainingIndeegree[successor]--; + if (remainingIndeegree[successor] == 0) + { + ready.Enqueue(successor); + } + } + } + + var cyclic = new HashSet(StringComparer.Ordinal); + if (scheduled != byId.Count) + { + foreach (var pair in remainingIndeegree) + { + if (pair.Value > 0) + { + cyclic.Add(pair.Key); + } + } + } + + return cyclic; + } + + private static HashSet FindUnreachableStepIds(Dictionary byId) + { + var successors = new Dictionary>(byId.Count, StringComparer.Ordinal); + foreach (var id in byId.Keys) + { + successors[id] = new List(); + } + + foreach (var registration in byId.Values) + { + foreach (var dependency in registration.DependsOn) + { + if (!byId.ContainsKey(dependency) + || string.Equals(dependency, registration.StepId, StringComparison.Ordinal)) + { + continue; + } + + successors[dependency].Add(registration.StepId); + } + } + + // Roots: no known non-self DependsOn edges (unknown/self edges do not create predecessors). + var roots = byId.Values + .Where(s => + { + foreach (var dep in s.DependsOn) + { + if (byId.ContainsKey(dep) && !string.Equals(dep, s.StepId, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + }) + .Select(static s => s.StepId) + .ToList(); + + var visited = new HashSet(StringComparer.Ordinal); + var queue = new Queue(roots); + while (queue.Count > 0) + { + var id = queue.Dequeue(); + if (!visited.Add(id)) + { + continue; + } + + foreach (var successor in successors[id]) + { + queue.Enqueue(successor); + } + } + + var unreachable = new HashSet(StringComparer.Ordinal); + foreach (var id in byId.Keys) + { + if (!visited.Contains(id)) + { + unreachable.Add(id); + } + } + + return unreachable; + } +} diff --git a/DesignPatterns.SourceGenerators/Generators/WorkGraphModels.cs b/DesignPatterns.SourceGenerators/Generators/WorkGraphModels.cs new file mode 100644 index 0000000..a01c14b --- /dev/null +++ b/DesignPatterns.SourceGenerators/Generators/WorkGraphModels.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; + +namespace DesignPatterns.SourceGenerators.Generators; + +/// +/// Holder type annotated with [WorkGraph] / [WorkGraph<TContext>]. +/// +internal sealed record WorkGraphHolderInfo( + string HolderName, + string? NamespaceName, + string HolderFullyQualifiedDisplayString, + string ContextFullyQualifiedDisplayString, + string ContextName, + LocationInfo Location); + +/// +/// One [WorkStep] registration bound to a holder. +/// +internal sealed record WorkStepInfo( + string StepId, + string ConstantName, + string ImplementationFullyQualifiedDisplayString, + string ImplementationName, + string HolderFullyQualifiedDisplayString, + EquatableArray DependsOn, + bool ImplementsContract, + LocationInfo Location) : IEquatable +{ + public bool Equals(WorkStepInfo? other) + { + if (other is null) + { + return false; + } + + return string.Equals(StepId, other.StepId, StringComparison.Ordinal) + && string.Equals(ConstantName, other.ConstantName, StringComparison.Ordinal) + && string.Equals(ImplementationFullyQualifiedDisplayString, other.ImplementationFullyQualifiedDisplayString, StringComparison.Ordinal) + && string.Equals(ImplementationName, other.ImplementationName, StringComparison.Ordinal) + && string.Equals(HolderFullyQualifiedDisplayString, other.HolderFullyQualifiedDisplayString, StringComparison.Ordinal) + && DependsOn.Equals(other.DependsOn) + && ImplementsContract == other.ImplementsContract + && Location.Equals(other.Location); + } + + public override int GetHashCode() + { + unchecked + { + var hash = StringComparer.Ordinal.GetHashCode(StepId ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ConstantName ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ImplementationFullyQualifiedDisplayString ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ImplementationName ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(HolderFullyQualifiedDisplayString ?? string.Empty); + hash = (hash * 31) + DependsOn.GetHashCode(); + hash = (hash * 31) + ImplementsContract.GetHashCode(); + hash = (hash * 31) + Location.GetHashCode(); + return hash; + } + } +} + +/// +/// Validated catalog ready for Keys + Create emission. +/// +internal sealed record WorkGraphEmitModel( + string HolderName, + string? NamespaceName, + string HolderFullyQualifiedDisplayString, + string ContextFullyQualifiedDisplayString, + EquatableArray Steps) : IEquatable +{ + public bool Equals(WorkGraphEmitModel? other) + { + if (other is null) + { + return false; + } + + return string.Equals(HolderName, other.HolderName, StringComparison.Ordinal) + && string.Equals(NamespaceName, other.NamespaceName, StringComparison.Ordinal) + && string.Equals(HolderFullyQualifiedDisplayString, other.HolderFullyQualifiedDisplayString, StringComparison.Ordinal) + && string.Equals(ContextFullyQualifiedDisplayString, other.ContextFullyQualifiedDisplayString, StringComparison.Ordinal) + && Steps.Equals(other.Steps); + } + + public override int GetHashCode() + { + unchecked + { + var hash = StringComparer.Ordinal.GetHashCode(HolderName ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(NamespaceName ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(HolderFullyQualifiedDisplayString ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ContextFullyQualifiedDisplayString ?? string.Empty); + hash = (hash * 31) + Steps.GetHashCode(); + return hash; + } + } +} + +/// +/// One step row in an emit model. +/// +internal sealed record WorkStepEmitModel( + string StepId, + string ConstantName, + EquatableArray DependsOnConstantNames) : IEquatable +{ + public bool Equals(WorkStepEmitModel? other) + { + if (other is null) + { + return false; + } + + return string.Equals(StepId, other.StepId, StringComparison.Ordinal) + && string.Equals(ConstantName, other.ConstantName, StringComparison.Ordinal) + && DependsOnConstantNames.Equals(other.DependsOnConstantNames); + } + + public override int GetHashCode() + { + unchecked + { + var hash = StringComparer.Ordinal.GetHashCode(StepId ?? string.Empty); + hash = (hash * 31) + StringComparer.Ordinal.GetHashCode(ConstantName ?? string.Empty); + hash = (hash * 31) + DependsOnConstantNames.GetHashCode(); + return hash; + } + } +} diff --git a/DesignPatterns.SourceGenerators/Syntax/WorkGraphSyntaxFactory.cs b/DesignPatterns.SourceGenerators/Syntax/WorkGraphSyntaxFactory.cs new file mode 100644 index 0000000..e8ce0bd --- /dev/null +++ b/DesignPatterns.SourceGenerators/Syntax/WorkGraphSyntaxFactory.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using DesignPatterns.SourceGenerators.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DesignPatterns.SourceGenerators.Syntax; + +internal static class WorkGraphSyntaxFactory +{ + public static CompilationUnitSyntax CreateCompilationUnit(WorkGraphEmitModel model) + { + var source = BuildSource(model); + var tree = CSharpSyntaxTree.ParseText(source); + var root = (CompilationUnitSyntax)tree.GetRoot(); + return root.NormalizeWhitespace(); + } + + public static string GetKeysClassName(string holderName) => holderName + "WorkStepKeys"; + + public static string GetFacadeClassName(string holderName) => holderName + "WorkGraph"; + + public static string ToConstantName(string key) + { + var parts = key.Split(new[] { '_', '-', ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return "Key"; + } + + return string.Concat(parts.Select(ToPascalCaseSegment)); + } + + private static string ToPascalCaseSegment(string segment) + { + if (string.IsNullOrEmpty(segment)) + { + return string.Empty; + } + + if (segment.Length == 1) + { + return segment.ToUpperInvariant(); + } + + return char.ToUpperInvariant(segment[0]) + segment.Substring(1); + } + + private static string BuildSource(WorkGraphEmitModel model) + { + var keysClass = GetKeysClassName(model.HolderName); + var facadeClass = GetFacadeClassName(model.HolderName); + var contextType = model.ContextFullyQualifiedDisplayString; + var steps = model.Steps.ToList(); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by DesignPatterns.SourceGenerators.WorkGraphGenerator"); + sb.AppendLine(); + sb.AppendLine($"#pragma warning disable {GeneratedCodeHelper.PragmaWarningDisableCodes}"); + sb.AppendLine(); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using DesignPatterns.Behavioral;"); + sb.AppendLine(); + + var indent = string.Empty; + if (!string.IsNullOrEmpty(model.NamespaceName)) + { + sb.AppendLine($"namespace {model.NamespaceName}"); + sb.AppendLine("{"); + indent = " "; + } + + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + sb.Append(indent).AppendLine("/// "); + sb.Append(indent).AppendLine($"/// Provides stable step ids for the {model.HolderName} work graph."); + sb.Append(indent).AppendLine("/// "); + sb.Append(indent).AppendLine("[global::System.CodeDom.Compiler.GeneratedCode(\"DesignPatterns.SourceGenerators\", \"1.0\")]"); + sb.Append(indent).AppendLine($"public static partial class {keysClass}"); + sb.Append(indent).AppendLine("{"); + foreach (var step in steps.OrderBy(static s => s.StepId, StringComparer.Ordinal)) + { + sb.Append(indent).AppendLine(" /// "); + sb.Append(indent).AppendLine($" /// The id for the {step.StepId} work step."); + sb.Append(indent).AppendLine(" /// "); + sb.Append(indent).AppendLine($" public const string {step.ConstantName} = \"{EscapeString(step.StepId)}\";"); + } + + sb.Append(indent).AppendLine("}"); + sb.AppendLine(); + + sb.Append(indent).AppendLine("/// "); + sb.Append(indent).AppendLine($"/// Builds an IWorkGraph for the {model.HolderName} attribute catalog."); + sb.Append(indent).AppendLine("/// "); + sb.Append(indent).AppendLine("[global::System.CodeDom.Compiler.GeneratedCode(\"DesignPatterns.SourceGenerators\", \"1.0\")]"); + sb.Append(indent).AppendLine($"public static partial class {facadeClass}"); + sb.Append(indent).AppendLine("{"); + + sb.Append(indent).AppendLine(" /// "); + sb.Append(indent).AppendLine(" /// Creates a work graph by resolving each catalog step id through ."); + sb.Append(indent).AppendLine(" /// "); + sb.Append(indent).AppendLine($" public static global::DesignPatterns.Behavioral.IWorkGraph<{contextType}> Create(global::System.Func> resolveById)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" if (resolveById is null)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" throw new global::System.ArgumentNullException(nameof(resolveById));"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine(); + sb.Append(indent).AppendLine($" var builder = new global::DesignPatterns.Behavioral.WorkGraphBuilder<{contextType}>();"); + foreach (var step in steps.OrderBy(static s => s.StepId, StringComparer.Ordinal)) + { + var keyRef = $"{keysClass}.{step.ConstantName}"; + if (step.DependsOnConstantNames.Count == 0) + { + sb.Append(indent).AppendLine($" builder.Add({keyRef}, resolveById({keyRef}));"); + } + else + { + var deps = string.Join(", ", step.DependsOnConstantNames.Select(c => $"{keysClass}.{c}")); + sb.Append(indent).AppendLine($" builder.Add({keyRef}, resolveById({keyRef}), {deps});"); + } + } + + sb.Append(indent).AppendLine(" return builder.Build();"); + sb.Append(indent).AppendLine(" }"); + sb.AppendLine(); + + sb.Append(indent).AppendLine(" /// "); + sb.Append(indent).AppendLine(" /// Creates a work graph from a dictionary of catalog step ids to step instances."); + sb.Append(indent).AppendLine(" /// "); + sb.Append(indent).AppendLine($" public static global::DesignPatterns.Behavioral.IWorkGraph<{contextType}> Create(global::System.Collections.Generic.IReadOnlyDictionary> steps)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" if (steps is null)"); + sb.Append(indent).AppendLine(" {"); + sb.Append(indent).AppendLine(" throw new global::System.ArgumentNullException(nameof(steps));"); + sb.Append(indent).AppendLine(" }"); + sb.Append(indent).AppendLine(); + sb.Append(indent).AppendLine($" return Create(id => steps[id]);"); + sb.Append(indent).AppendLine(" }"); + + sb.Append(indent).AppendLine("}"); + + if (!string.IsNullOrEmpty(model.NamespaceName)) + { + sb.AppendLine("}"); + } + + return sb.ToString(); + } + + private static string EscapeString(string value) => + value.Replace("\\", "\\\\").Replace("\"", "\\\""); +} From 35fa99c2e8301f08ada3d2bbe0a241e30d912968 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:16:10 +0000 Subject: [PATCH 2/4] Add Work Graph generator Verify coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock Keys/Create emission for diamond and multi-root catalogs plus the DP087–DP092 diagnostic matrix. Co-authored-by: 落笔wys --- ...ndCreateFacadeForDiamondGraph.verified.txt | 75 ++++ ...rNonGenericWorkGraphAttribute.verified.txt | 60 ++++ ...otGraphDoesNotWarnUnreachable.verified.txt | 70 ++++ ...Dp087WhenDependsOnFormsACycle.verified.txt | 14 + ...tsDp088WhenDependsOnIsUnknown.verified.txt | 6 + ...tsDp089WhenStepIdIsDuplicated.verified.txt | 6 + ...sDp090WhenStepDependsOnItself.verified.txt | 6 + ...henStepIsUnreachableFromRoots.verified.txt | 14 + ...StepDoesNotImplementIWorkStep.verified.txt | 6 + .../Generators/WorkGraphGeneratorTests.cs | 322 ++++++++++++++++++ 10 files changed, 579 insertions(+) create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForDiamondGraph.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForNonGenericWorkGraphAttribute.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.MultiRootGraphDoesNotWarnUnreachable.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp087WhenDependsOnFormsACycle.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp088WhenDependsOnIsUnknown.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp089WhenStepIdIsDuplicated.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp090WhenStepDependsOnItself.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp091WhenStepIsUnreachableFromRoots.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp092WhenStepDoesNotImplementIWorkStep.verified.txt create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForDiamondGraph.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForDiamondGraph.verified.txt new file mode 100644 index 0000000..399ed72 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForDiamondGraph.verified.txt @@ -0,0 +1,75 @@ +{ + TestAssembly_RequestPrep.WorkGraph.g.cs: +// +// Generated by DesignPatterns.SourceGenerators.WorkGraphGenerator +#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618 +using System; +using System.Collections.Generic; +using DesignPatterns.Behavioral; + +namespace TestAssembly +{ +#nullable enable + /// + /// Provides stable step ids for the RequestPrep work graph. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class RequestPrepWorkStepKeys + { + /// + /// The id for the auth work step. + /// + public const string Auth = "auth"; + /// + /// The id for the authorize work step. + /// + public const string Authorize = "authorize"; + /// + /// The id for the build-principal work step. + /// + public const string BuildPrincipal = "build-principal"; + /// + /// The id for the load-config work step. + /// + public const string LoadConfig = "load-config"; + } + + /// + /// Builds an IWorkGraph for the RequestPrep attribute catalog. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class RequestPrepWorkGraph + { + /// + /// Creates a work graph by resolving each catalog step id through . + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Func> resolveById) + { + if (resolveById is null) + { + throw new global::System.ArgumentNullException(nameof(resolveById)); + } + + var builder = new global::DesignPatterns.Behavioral.WorkGraphBuilder(); + builder.Add(RequestPrepWorkStepKeys.Auth, resolveById(RequestPrepWorkStepKeys.Auth)); + builder.Add(RequestPrepWorkStepKeys.Authorize, resolveById(RequestPrepWorkStepKeys.Authorize), RequestPrepWorkStepKeys.BuildPrincipal); + builder.Add(RequestPrepWorkStepKeys.BuildPrincipal, resolveById(RequestPrepWorkStepKeys.BuildPrincipal), RequestPrepWorkStepKeys.Auth, RequestPrepWorkStepKeys.LoadConfig); + builder.Add(RequestPrepWorkStepKeys.LoadConfig, resolveById(RequestPrepWorkStepKeys.LoadConfig)); + return builder.Build(); + } + + /// + /// Creates a work graph from a dictionary of catalog step ids to step instances. + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Collections.Generic.IReadOnlyDictionary> steps) + { + if (steps is null) + { + throw new global::System.ArgumentNullException(nameof(steps)); + } + + return Create(id => steps[id]); + } + } +} +} \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForNonGenericWorkGraphAttribute.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForNonGenericWorkGraphAttribute.verified.txt new file mode 100644 index 0000000..31cc98f --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.GeneratesKeysAndCreateFacadeForNonGenericWorkGraphAttribute.verified.txt @@ -0,0 +1,60 @@ +{ + TestAssembly_SimpleGraph.WorkGraph.g.cs: +// +// Generated by DesignPatterns.SourceGenerators.WorkGraphGenerator +#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618 +using System; +using System.Collections.Generic; +using DesignPatterns.Behavioral; + +namespace TestAssembly +{ +#nullable enable + /// + /// Provides stable step ids for the SimpleGraph work graph. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class SimpleGraphWorkStepKeys + { + /// + /// The id for the only work step. + /// + public const string Only = "only"; + } + + /// + /// Builds an IWorkGraph for the SimpleGraph attribute catalog. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class SimpleGraphWorkGraph + { + /// + /// Creates a work graph by resolving each catalog step id through . + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Func> resolveById) + { + if (resolveById is null) + { + throw new global::System.ArgumentNullException(nameof(resolveById)); + } + + var builder = new global::DesignPatterns.Behavioral.WorkGraphBuilder(); + builder.Add(SimpleGraphWorkStepKeys.Only, resolveById(SimpleGraphWorkStepKeys.Only)); + return builder.Build(); + } + + /// + /// Creates a work graph from a dictionary of catalog step ids to step instances. + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Collections.Generic.IReadOnlyDictionary> steps) + { + if (steps is null) + { + throw new global::System.ArgumentNullException(nameof(steps)); + } + + return Create(id => steps[id]); + } + } +} +} \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.MultiRootGraphDoesNotWarnUnreachable.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.MultiRootGraphDoesNotWarnUnreachable.verified.txt new file mode 100644 index 0000000..1b13b51 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.MultiRootGraphDoesNotWarnUnreachable.verified.txt @@ -0,0 +1,70 @@ +{ + TestAssembly_MultiRoot.WorkGraph.g.cs: +// +// Generated by DesignPatterns.SourceGenerators.WorkGraphGenerator +#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618 +using System; +using System.Collections.Generic; +using DesignPatterns.Behavioral; + +namespace TestAssembly +{ +#nullable enable + /// + /// Provides stable step ids for the MultiRoot work graph. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class MultiRootWorkStepKeys + { + /// + /// The id for the auth work step. + /// + public const string Auth = "auth"; + /// + /// The id for the config work step. + /// + public const string Config = "config"; + /// + /// The id for the join work step. + /// + public const string Join = "join"; + } + + /// + /// Builds an IWorkGraph for the MultiRoot attribute catalog. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class MultiRootWorkGraph + { + /// + /// Creates a work graph by resolving each catalog step id through . + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Func> resolveById) + { + if (resolveById is null) + { + throw new global::System.ArgumentNullException(nameof(resolveById)); + } + + var builder = new global::DesignPatterns.Behavioral.WorkGraphBuilder(); + builder.Add(MultiRootWorkStepKeys.Auth, resolveById(MultiRootWorkStepKeys.Auth)); + builder.Add(MultiRootWorkStepKeys.Config, resolveById(MultiRootWorkStepKeys.Config)); + builder.Add(MultiRootWorkStepKeys.Join, resolveById(MultiRootWorkStepKeys.Join), MultiRootWorkStepKeys.Auth, MultiRootWorkStepKeys.Config); + return builder.Build(); + } + + /// + /// Creates a work graph from a dictionary of catalog step ids to step instances. + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Collections.Generic.IReadOnlyDictionary> steps) + { + if (steps is null) + { + throw new global::System.ArgumentNullException(nameof(steps)); + } + + return Create(id => steps[id]); + } + } +} +} \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp087WhenDependsOnFormsACycle.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp087WhenDependsOnFormsACycle.verified.txt new file mode 100644 index 0000000..2f07f47 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp087WhenDependsOnFormsACycle.verified.txt @@ -0,0 +1,14 @@ +[ + { + Id: DP087, + Message: Work graph 'CycleGraph' contains a cycle involving step id(s): a, b. Remove or reassign the cyclic DependsOn edge(s). + }, + { + Id: DP091, + Message: Step 'a' on work graph 'CycleGraph' is unreachable from any root (a step with no DependsOn). Connect it via DependsOn or remove the orphaned step. Multi-root graphs remain valid. + }, + { + Id: DP091, + Message: Step 'b' on work graph 'CycleGraph' is unreachable from any root (a step with no DependsOn). Connect it via DependsOn or remove the orphaned step. Multi-root graphs remain valid. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp088WhenDependsOnIsUnknown.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp088WhenDependsOnIsUnknown.verified.txt new file mode 100644 index 0000000..9290977 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp088WhenDependsOnIsUnknown.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP088, + Message: Step 'a' on work graph 'UnknownDepGraph' depends on unknown step id 'missing'. Register a [WorkStep] with that id or remove it from DependsOn. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp089WhenStepIdIsDuplicated.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp089WhenStepIdIsDuplicated.verified.txt new file mode 100644 index 0000000..a423f9e --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp089WhenStepIdIsDuplicated.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP089, + Message: Step id 'same' is declared more than once on work graph 'DupGraph'. Rename one of the [WorkStep] Id values so each step id is unique. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp090WhenStepDependsOnItself.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp090WhenStepDependsOnItself.verified.txt new file mode 100644 index 0000000..ba265af --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp090WhenStepDependsOnItself.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP090, + Message: Step 'loop' on work graph 'SelfGraph' declares a self-dependency. Remove 'loop' from its DependsOn list. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp091WhenStepIsUnreachableFromRoots.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp091WhenStepIsUnreachableFromRoots.verified.txt new file mode 100644 index 0000000..0a9dd28 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp091WhenStepIsUnreachableFromRoots.verified.txt @@ -0,0 +1,14 @@ +[ + { + Id: DP087, + Message: Work graph 'OrphanGraph' contains a cycle involving step id(s): a, b. Remove or reassign the cyclic DependsOn edge(s). + }, + { + Id: DP091, + Message: Step 'a' on work graph 'OrphanGraph' is unreachable from any root (a step with no DependsOn). Connect it via DependsOn or remove the orphaned step. Multi-root graphs remain valid. + }, + { + Id: DP091, + Message: Step 'b' on work graph 'OrphanGraph' is unreachable from any root (a step with no DependsOn). Connect it via DependsOn or remove the orphaned step. Multi-root graphs remain valid. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp092WhenStepDoesNotImplementIWorkStep.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp092WhenStepDoesNotImplementIWorkStep.verified.txt new file mode 100644 index 0000000..7a8f311 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.ReportsDp092WhenStepDoesNotImplementIWorkStep.verified.txt @@ -0,0 +1,6 @@ +[ + { + Id: DP092, + Message: Type 'NotAStep' is marked [WorkStep] for work graph 'MismatchGraph' but does not implement IWorkStep. Implement IWorkStep or fix the holder / attribute arguments. + } +] \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs new file mode 100644 index 0000000..1eabdf7 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs @@ -0,0 +1,322 @@ +using DesignPatterns.SourceGenerators.Generators; + +namespace DesignPatterns.SourceGenerators.Tests.Generators; + +/// +/// Seam: generated {Holder}WorkStepKeys / {Holder}WorkGraph.Create +/// and Work Graph diagnostics DP087–DP092 (issue #311). +/// +public sealed class WorkGraphGeneratorTests +{ + [Fact] + public Task GeneratesKeysAndCreateFacadeForDiamondGraph() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class PrepContext + { + public string Principal { get; set; } = ""; + } + + [WorkGraph] + public static class RequestPrep + { + } + + [WorkStep(typeof(RequestPrep), Id = "auth")] + public sealed class AuthStep : IWorkStep + { + public ValueTask ExecuteAsync(PrepContext context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(RequestPrep), Id = "load-config")] + public sealed class LoadConfigStep : IWorkStep + { + public ValueTask ExecuteAsync(PrepContext context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(RequestPrep), Id = "build-principal", DependsOn = new[] { "auth", "load-config" })] + public sealed class BuildPrincipalStep : IWorkStep + { + public ValueTask ExecuteAsync(PrepContext context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(RequestPrep), Id = "authorize", DependsOn = new[] { "build-principal" })] + public sealed class AuthorizeStep : IWorkStep + { + public ValueTask ExecuteAsync(PrepContext context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("RequestPrep.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); + } + + [Fact] + public Task GeneratesKeysAndCreateFacadeForNonGenericWorkGraphAttribute() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph(typeof(Ctx))] + public static class SimpleGraph + { + } + + [WorkStep(typeof(SimpleGraph), Id = "only")] + public sealed class OnlyStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("SimpleGraph.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); + } + + [Fact] + public Task ReportsDp089WhenStepIdIsDuplicated() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class DupGraph { } + + [WorkStep(typeof(DupGraph), Id = "same")] + public sealed class First : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(DupGraph), Id = "same")] + public sealed class Second : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("DupGraph.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp090WhenStepDependsOnItself() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class SelfGraph { } + + [WorkStep(typeof(SelfGraph), Id = "loop", DependsOn = new[] { "loop" })] + public sealed class LoopStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("SelfGraph.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp088WhenDependsOnIsUnknown() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class UnknownDepGraph { } + + [WorkStep(typeof(UnknownDepGraph), Id = "a", DependsOn = new[] { "missing" })] + public sealed class AStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("UnknownDepGraph.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp087WhenDependsOnFormsACycle() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class CycleGraph { } + + [WorkStep(typeof(CycleGraph), Id = "a", DependsOn = new[] { "b" })] + public sealed class AStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(CycleGraph), Id = "b", DependsOn = new[] { "a" })] + public sealed class BStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("CycleGraph.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp091WhenStepIsUnreachableFromRoots() + { + // Root R is reachable; A↔B form a cycle and are unreachable from R. + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class OrphanGraph { } + + [WorkStep(typeof(OrphanGraph), Id = "root")] + public sealed class RootStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(OrphanGraph), Id = "a", DependsOn = new[] { "b" })] + public sealed class AStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(OrphanGraph), Id = "b", DependsOn = new[] { "a" })] + public sealed class BStep : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("OrphanGraph.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task ReportsDp092WhenStepDoesNotImplementIWorkStep() + { + const string source = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class MismatchGraph { } + + [WorkStep(typeof(MismatchGraph), Id = "bad")] + public sealed class NotAStep + { + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("MismatchGraph.cs", source)); + + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + } + + [Fact] + public Task MultiRootGraphDoesNotWarnUnreachable() + { + const string source = """ + using System.Threading; + using System.Threading.Tasks; + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class MultiRoot { } + + [WorkStep(typeof(MultiRoot), Id = "auth")] + public sealed class Auth : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(MultiRoot), Id = "config")] + public sealed class Config : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + + [WorkStep(typeof(MultiRoot), Id = "join", DependsOn = new[] { "auth", "config" })] + public sealed class Join : IWorkStep + { + public ValueTask ExecuteAsync(Ctx context, CancellationToken cancellationToken = default) => default; + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("MultiRoot.cs", source)); + + Assert.Empty(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); + } +} From 43a01a627069acb4d244860abf555a75196615d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:16:10 +0000 Subject: [PATCH 3/4] Document Work Graph generator in CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the SourceGenerators dual-path emission and DP087–DP092 reporting under Unreleased. Co-authored-by: 落笔wys --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c936d..f8c71ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Work Graph generator**: `[WorkGraph]` / `[WorkGraph]` + `[WorkStep]` drive `WorkGraphGenerator`, emitting `{Holder}WorkStepKeys` and `{Holder}WorkGraph.Create(resolver|dictionary)` that fills `WorkGraphBuilder`. Reports **DP087–DP092** (cycle, unknown DependsOn, duplicate id, self-dependency, unreachable Warning, contract mismatch); no MVP DI/Autofac emission ([#311](https://github.com/Skymly/DesignPatterns/issues/311), Spec [#308](https://github.com/Skymly/DesignPatterns/issues/308)). - **Work Graph diagnostic IDs**: reserved **DP087–DP092** for the upcoming Work Graph generator (dependency cycle, unknown DependsOn, duplicate step id, self-dependency, unreachable step Warning, contract/TContext mismatch); no unregistered-`IWorkStep` Analyzer in MVP; DP067–DP071 remain ADR-008-only ([#310](https://github.com/Skymly/DesignPatterns/issues/310), Spec [#308](https://github.com/Skymly/DesignPatterns/issues/308)). ### Changed From f9dc5a2e0e9619a3a4385c067519ba842571e1ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 13:20:00 +0000 Subject: [PATCH 4/4] Harden Work Graph empty catalog and adjacency helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit Create for empty [WorkGraph] holders so Build rejects at runtime, share successor-map construction, and drop dead DependsOn parsing. Co-authored-by: 落笔wys --- .../Generators/WorkGraphGenerator.cs | 57 ++++++------------- ...tRejectsEmptyCatalogAtRuntime.verified.txt | 55 ++++++++++++++++++ .../Generators/WorkGraphGeneratorTests.cs | 23 ++++++++ 3 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.EmitsCreateThatRejectsEmptyCatalogAtRuntime.verified.txt diff --git a/DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs b/DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs index a11050b..225c1d1 100644 --- a/DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs +++ b/DesignPatterns.SourceGenerators/Generators/WorkGraphGenerator.cs @@ -193,12 +193,6 @@ private static string[] ExtractDependsOn(TypedConstant value) { list.Add(s); } - else if (element.Value is string) - { - // Preserve blank entries so unknown/self validation can still see them as errors? - // Spec: null/whitespace DependsOn is invalid at runtime. Report as unknown with empty? - // Skip blanks here; blank ids are not registered step ids. - } } return list.ToArray(); @@ -287,12 +281,8 @@ private static void Execute( foreach (var holder in holders.OrderBy(static h => h.HolderFullyQualifiedDisplayString, StringComparer.Ordinal)) { - if (!stepsByHolder.TryGetValue(holder.HolderFullyQualifiedDisplayString, out var steps) - || steps.Count == 0) - { - // Empty catalog: no Keys/Create emission (runtime Build still rejects empty). - continue; - } + stepsByHolder.TryGetValue(holder.HolderFullyQualifiedDisplayString, out var steps); + steps ??= new List(); if (!TryBuildEmitModel(context, holder, steps, out var model)) { @@ -403,6 +393,7 @@ private static bool TryBuildEmitModel( // Warnings alone do not block emission; errors do. // Unreachable with a cycle is always accompanied by DP087 (error), so emission is blocked. // Pure unreachable without cycle cannot occur in a valid DAG — keep warning-only path emit-safe. + // Empty catalogs still emit Create so Build() rejects at runtime (Spec: empty → Create Error). if (hasError) { return false; @@ -437,10 +428,13 @@ private static bool TryBuildEmitModel( return true; } - private static HashSet FindCyclicStepIds(Dictionary byId) + private static void BuildAdjacency( + Dictionary byId, + out Dictionary remainingIndeegree, + out Dictionary> successors) { - var remainingIndeegree = new Dictionary(byId.Count, StringComparer.Ordinal); - var successors = new Dictionary>(byId.Count, StringComparer.Ordinal); + remainingIndeegree = new Dictionary(byId.Count, StringComparer.Ordinal); + successors = new Dictionary>(byId.Count, StringComparer.Ordinal); foreach (var id in byId.Keys) { @@ -452,12 +446,8 @@ private static HashSet FindCyclicStepIds(Dictionary FindCyclicStepIds(Dictionary FindCyclicStepIds(Dictionary byId) + { + BuildAdjacency(byId, out var remainingIndeegree, out var successors); var ready = new Queue(); foreach (var pair in remainingIndeegree) @@ -508,25 +503,7 @@ private static HashSet FindCyclicStepIds(Dictionary FindUnreachableStepIds(Dictionary byId) { - var successors = new Dictionary>(byId.Count, StringComparer.Ordinal); - foreach (var id in byId.Keys) - { - successors[id] = new List(); - } - - foreach (var registration in byId.Values) - { - foreach (var dependency in registration.DependsOn) - { - if (!byId.ContainsKey(dependency) - || string.Equals(dependency, registration.StepId, StringComparison.Ordinal)) - { - continue; - } - - successors[dependency].Add(registration.StepId); - } - } + BuildAdjacency(byId, out _, out var successors); // Roots: no known non-self DependsOn edges (unknown/self edges do not create predecessors). var roots = byId.Values diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.EmitsCreateThatRejectsEmptyCatalogAtRuntime.verified.txt b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.EmitsCreateThatRejectsEmptyCatalogAtRuntime.verified.txt new file mode 100644 index 0000000..88dc740 --- /dev/null +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.EmitsCreateThatRejectsEmptyCatalogAtRuntime.verified.txt @@ -0,0 +1,55 @@ +{ + TestAssembly_EmptyGraph.WorkGraph.g.cs: +// +// Generated by DesignPatterns.SourceGenerators.WorkGraphGenerator +#pragma warning disable CS1591, CS8019, CS0162, CS0612, CS0618 +using System; +using System.Collections.Generic; +using DesignPatterns.Behavioral; + +namespace TestAssembly +{ +#nullable enable + /// + /// Provides stable step ids for the EmptyGraph work graph. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class EmptyGraphWorkStepKeys + { + } + + /// + /// Builds an IWorkGraph for the EmptyGraph attribute catalog. + /// + [global::System.CodeDom.Compiler.GeneratedCode("DesignPatterns.SourceGenerators", "1.0")] + public static partial class EmptyGraphWorkGraph + { + /// + /// Creates a work graph by resolving each catalog step id through . + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Func> resolveById) + { + if (resolveById is null) + { + throw new global::System.ArgumentNullException(nameof(resolveById)); + } + + var builder = new global::DesignPatterns.Behavioral.WorkGraphBuilder(); + return builder.Build(); + } + + /// + /// Creates a work graph from a dictionary of catalog step ids to step instances. + /// + public static global::DesignPatterns.Behavioral.IWorkGraph Create(global::System.Collections.Generic.IReadOnlyDictionary> steps) + { + if (steps is null) + { + throw new global::System.ArgumentNullException(nameof(steps)); + } + + return Create(id => steps[id]); + } + } +} +} \ No newline at end of file diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs index 1eabdf7..d21c1fa 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/WorkGraphGeneratorTests.cs @@ -279,6 +279,29 @@ public sealed class NotAStep return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); } + [Fact] + public Task EmitsCreateThatRejectsEmptyCatalogAtRuntime() + { + const string source = """ + using DesignPatterns.Behavioral; + + namespace TestAssembly; + + public sealed class Ctx { } + + [WorkGraph] + public static class EmptyGraph + { + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("EmptyGraph.cs", source)); + + Assert.Empty(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); + return Verifier.Verify(SourceGeneratorTestContext.GetGeneratedSources(runResult)); + } + [Fact] public Task MultiRootGraphDoesNotWarnUnreachable() {