From fd689748c91a3aa20059546d9e6887fce207a7ae Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 18 Apr 2026 17:42:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20analyzer=20expansion=20?= =?UTF-8?q?=E2=80=94=20SubWorkflow/Fork/state+payload=20guards=20+=20helpe?= =?UTF-8?q?r-context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port analyzer improvements developed downstream that extend canonical artifact emission and non-trusted-call exemptions: - WorkflowCanonicalArtifactGenerator: SubWorkflow / Fork / WhenStateEquals / WhenPayloadEquals step handlers; Call desugaring with WorkflowHandledBranchAction; HelperContext with parent chain and ResolveParameter identifier chase for multi-hop parameter forwarding; fluent-helper inliner (TryInlineFluentHelper + WalkFluentHelper*); spec-level inliner (TryInlineSpecHelper); JSON-fragment loader surfaced via AdditionalFiles (TryResolveLazyFragmentValue + TryResolveDirectFragmentCall); ContinueWith HelperContext threading; null-coalesce support; const-name in ParseNamedExpr; conditional-spread via TryExpandConditionalSpread. - CanonicalSteps: add SubWorkflowStep and ForkStep IR classes. - CanonicalJsonFragmentParser (new): minimal recursive-descent JSON→CanonicalExpr parser to support compile-time inlining of pre-built WorkflowExpressionDefinition fragments loaded at runtime via LoadFragment. - WorkflowCanonicalityAnalyzer: helpers returning trusted workflow types (WorkflowSpec, WorkflowExpressionDefinition, etc.) are now treated as compile-time construction factories and exempt from WF010 — needed for the fluent-helper inliner to cover real-world plugin patterns. - Tests: AnalyzerTestHarness gains an additionalTexts overload (with InMemoryAdditionalText); GeneratorStepsTests adds coverage for the new handlers and inliners; NonTrustedCallTests inverts CallingHelperThatHasImperative to assert the new WF010 exemption for helpers returning trusted workflow types. Verified: 51/51 analyzer tests pass (net10.0). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Emission/CanonicalJsonFragmentParser.cs | 400 +++ .../Emission/CanonicalSteps.cs | 163 ++ .../WorkflowCanonicalArtifactGenerator.cs | 2434 ++++++++++++++++- .../WorkflowCanonicalityAnalyzer.cs | 52 + .../WorkflowDiagnostics.cs | 4 +- .../AnalyzerTestHarness.cs | 26 +- .../GeneratorStepsTests.cs | 556 +++- .../NonTrustedCallTests.cs | 10 +- 8 files changed, 3564 insertions(+), 81 deletions(-) create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalJsonFragmentParser.cs diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalJsonFragmentParser.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalJsonFragmentParser.cs new file mode 100644 index 000000000..0050f5c9e --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalJsonFragmentParser.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace StellaOps.Workflow.Analyzer.Emission; + +/// +/// Minimal recursive-descent JSON parser that produces nodes +/// matching the polymorphic $type discriminators used in the runtime contract and in +/// serialised canonical fragments. +/// +/// +/// +/// This parser is intentionally narrow. It only accepts the shape that the canonical JSON +/// writer itself emits: every expression-object has a $type field and the shape that +/// type dictates (see WorkflowExpressionDefinition's JsonDerivedType attributes). +/// Anything else throws . +/// +/// +/// The parser runs inside the Roslyn source generator, which targets netstandard2.0 without +/// System.Text.Json available. Hand-rolling keeps dependencies zero. +/// +/// +internal static class CanonicalJsonFragmentParser +{ + public static CanonicalExpr Parse(string json) + { + var reader = new JsonReader(json); + reader.SkipWhitespace(); + var value = ReadExpression(ref reader); + reader.SkipWhitespace(); + if (!reader.IsAtEnd) + { + throw new FragmentParseException($"unexpected trailing content at position {reader.Position}"); + } + return value; + } + + private static CanonicalExpr ReadExpression(ref JsonReader reader) + { + reader.SkipWhitespace(); + reader.Expect('{'); + var members = new Dictionary(StringComparer.Ordinal); + var first = true; + while (true) + { + reader.SkipWhitespace(); + if (reader.Peek() == '}') + { + reader.Advance(); + break; + } + if (!first) + { + reader.Expect(','); + reader.SkipWhitespace(); + } + var key = reader.ReadString(); + reader.SkipWhitespace(); + reader.Expect(':'); + reader.SkipWhitespace(); + members[key] = ReadValue(ref reader); + first = false; + } + + if (!members.TryGetValue("$type", out var typeValue) || typeValue is not string typeName) + { + throw new FragmentParseException("expression object missing '$type' discriminator"); + } + + return typeName switch + { + "null" => new NullExpr(), + "string" => new StringExpr(RequireString(members, "value")), + "number" => new NumberExpr(RequireString(members, "value")), + "boolean" => new BoolExpr(RequireBool(members, "value")), + "path" => new PathExpr(RequireString(members, "path")), + "object" => new ObjectExpr(ReadProperties(RequireArray(members, "properties"))), + "array" => new ArrayExpr(CastItems(RequireArray(members, "items"))), + "function" => new FunctionExpr( + RequireString(members, "functionName"), + CastItems(RequireArray(members, "arguments"))), + "group" => new GroupExpr(RequireExpr(members, "expression")), + "unary" => new UnaryExpr(RequireString(members, "operator"), RequireExpr(members, "operand")), + "binary" => new BinaryExpr( + RequireString(members, "operator"), + RequireExpr(members, "left"), + RequireExpr(members, "right")), + _ => throw new FragmentParseException($"unsupported canonical expression $type '{typeName}'"), + }; + } + + private static object? ReadValue(ref JsonReader reader) + { + reader.SkipWhitespace(); + var c = reader.Peek(); + return c switch + { + '{' => ReadObjectOrExpression(ref reader), + '[' => ReadArray(ref reader), + '"' => reader.ReadString(), + 't' or 'f' => reader.ReadBoolLiteral(), + 'n' => reader.ReadNullLiteral(), + _ when c == '-' || (c >= '0' && c <= '9') => reader.ReadNumberLiteral(), + _ => throw new FragmentParseException($"unexpected character '{c}' at position {reader.Position}"), + }; + } + + /// + /// Either a canonical expression (when the object has $type) or a plain property + /// carrier (used for properties[] entries which are {name, expression}). + /// + private static object ReadObjectOrExpression(ref JsonReader reader) + { + var start = reader.Position; + reader.Advance(); // consume '{' + var members = new Dictionary(StringComparer.Ordinal); + var first = true; + while (true) + { + reader.SkipWhitespace(); + if (reader.Peek() == '}') + { + reader.Advance(); + break; + } + if (!first) + { + reader.Expect(','); + reader.SkipWhitespace(); + } + var key = reader.ReadString(); + reader.SkipWhitespace(); + reader.Expect(':'); + reader.SkipWhitespace(); + members[key] = ReadValue(ref reader); + first = false; + } + + // Canonical expression — has $type. + if (members.TryGetValue("$type", out var typeValue) && typeValue is string typeName) + { + return DispatchExpression(typeName, members, start); + } + + // Plain carrier: either {"name": "...", "expression": {...}} (ObjectExpr property) or + // a free-form bag we hand back for the caller to interpret. + return members; + } + + private static CanonicalExpr DispatchExpression(string typeName, Dictionary members, int startPos) + { + return typeName switch + { + "null" => new NullExpr(), + "string" => new StringExpr(RequireString(members, "value")), + "number" => new NumberExpr(RequireString(members, "value")), + "boolean" => new BoolExpr(RequireBool(members, "value")), + "path" => new PathExpr(RequireString(members, "path")), + "object" => new ObjectExpr(ReadProperties(RequireArray(members, "properties"))), + "array" => new ArrayExpr(CastItems(RequireArray(members, "items"))), + "function" => new FunctionExpr( + RequireString(members, "functionName"), + CastItems(RequireArray(members, "arguments"))), + "group" => new GroupExpr(RequireExpr(members, "expression")), + "unary" => new UnaryExpr(RequireString(members, "operator"), RequireExpr(members, "operand")), + "binary" => new BinaryExpr( + RequireString(members, "operator"), + RequireExpr(members, "left"), + RequireExpr(members, "right")), + _ => throw new FragmentParseException($"unsupported canonical expression $type '{typeName}' at position {startPos}"), + }; + } + + private static List ReadArray(ref JsonReader reader) + { + reader.Advance(); // consume '[' + var items = new List(); + var first = true; + while (true) + { + reader.SkipWhitespace(); + if (reader.Peek() == ']') + { + reader.Advance(); + break; + } + if (!first) + { + reader.Expect(','); + reader.SkipWhitespace(); + } + items.Add(ReadValue(ref reader)); + first = false; + } + return items; + } + + private static List ReadProperties(List raw) + { + var props = new List(raw.Count); + foreach (var item in raw) + { + if (item is not Dictionary bag) + { + throw new FragmentParseException("object 'properties' must contain {name, expression} bags"); + } + var name = bag.TryGetValue("name", out var n) && n is string s + ? s + : throw new FragmentParseException("object property missing 'name' string"); + if (!bag.TryGetValue("expression", out var e) || e is not CanonicalExpr expr) + { + throw new FragmentParseException($"object property '{name}' missing 'expression'"); + } + props.Add(new NamedExpr(name, expr)); + } + return props; + } + + private static List CastItems(List raw) + { + var items = new List(raw.Count); + foreach (var item in raw) + { + if (item is not CanonicalExpr expr) + { + throw new FragmentParseException("array items must be canonical expressions"); + } + items.Add(expr); + } + return items; + } + + private static string RequireString(Dictionary members, string key) + { + if (!members.TryGetValue(key, out var value) || value is not string s) + { + throw new FragmentParseException($"expected string member '{key}'"); + } + return s; + } + + private static bool RequireBool(Dictionary members, string key) + { + if (!members.TryGetValue(key, out var value) || value is not bool b) + { + throw new FragmentParseException($"expected boolean member '{key}'"); + } + return b; + } + + private static List RequireArray(Dictionary members, string key) + { + if (!members.TryGetValue(key, out var value) || value is not List arr) + { + throw new FragmentParseException($"expected array member '{key}'"); + } + return arr; + } + + private static CanonicalExpr RequireExpr(Dictionary members, string key) + { + if (!members.TryGetValue(key, out var value) || value is not CanonicalExpr expr) + { + throw new FragmentParseException($"expected canonical-expression member '{key}'"); + } + return expr; + } + + private ref struct JsonReader + { + private readonly string source; + private int pos; + + public JsonReader(string source) + { + this.source = source; + pos = 0; + } + + public int Position => pos; + public bool IsAtEnd => pos >= source.Length; + + public char Peek() => pos < source.Length ? source[pos] : '\0'; + public void Advance() => pos++; + + public void Expect(char c) + { + if (pos >= source.Length || source[pos] != c) + { + throw new FragmentParseException($"expected '{c}' at position {pos}, got '{(pos < source.Length ? source[pos] : '\0')}'"); + } + pos++; + } + + public void SkipWhitespace() + { + while (pos < source.Length) + { + var c = source[pos]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') pos++; + else break; + } + } + + public string ReadString() + { + Expect('"'); + var sb = new StringBuilder(); + while (pos < source.Length) + { + var c = source[pos++]; + if (c == '"') return sb.ToString(); + if (c == '\\') + { + if (pos >= source.Length) throw new FragmentParseException("unterminated escape"); + var esc = source[pos++]; + switch (esc) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'u': + if (pos + 4 > source.Length) throw new FragmentParseException("truncated \\u escape"); + var hex = source.Substring(pos, 4); + pos += 4; + sb.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + break; + default: + throw new FragmentParseException($"unsupported escape '\\{esc}' at position {pos - 1}"); + } + } + else + { + sb.Append(c); + } + } + throw new FragmentParseException("unterminated string"); + } + + public bool ReadBoolLiteral() + { + if (pos + 4 <= source.Length && source.Substring(pos, 4) == "true") + { + pos += 4; + return true; + } + if (pos + 5 <= source.Length && source.Substring(pos, 5) == "false") + { + pos += 5; + return false; + } + throw new FragmentParseException($"expected true/false at position {pos}"); + } + + public object? ReadNullLiteral() + { + if (pos + 4 <= source.Length && source.Substring(pos, 4) == "null") + { + pos += 4; + return null; + } + throw new FragmentParseException($"expected null at position {pos}"); + } + + public string ReadNumberLiteral() + { + var start = pos; + if (source[pos] == '-') pos++; + while (pos < source.Length) + { + var c = source[pos]; + if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') + { + pos++; + } + else + { + break; + } + } + return source.Substring(start, pos - start); + } + } +} + +/// +/// Thrown when a canonical-JSON fragment can't be parsed into the IR. The generator turns +/// these into a WF020 diagnostic on the helper call site so the workflow still builds. +/// +internal sealed class FragmentParseException : Exception +{ + public FragmentParseException(string message) : base(message) { } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalSteps.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalSteps.cs index f662c5f53..e18e5af9e 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalSteps.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/Emission/CanonicalSteps.cs @@ -133,11 +133,14 @@ public sealed class TransportCallStep : CanonicalStep w.BeginObject(); w.Property("$type"); w.StringValue("call-transport"); w.Property("stepName"); w.StringValue(StepName); + w.Property("invocation"); + w.BeginObject(); w.Property("address"); Address.WriteTo(w); if (PayloadExpression is not null) { w.Property("payloadExpression"); PayloadExpression.WriteTo(w); } + w.EndObject(); if (ResultKey is not null) { w.Property("resultKey"); w.StringValue(ResultKey); @@ -173,6 +176,8 @@ public sealed class AssignBusinessReferenceStep : CanonicalStep { w.BeginObject(); w.Property("$type"); w.StringValue("assign-business-reference"); + w.Property("businessReference"); + w.BeginObject(); if (KeyExpression is not null) { w.Property("keyExpression"); KeyExpression.WriteTo(w); @@ -182,6 +187,164 @@ public sealed class AssignBusinessReferenceStep : CanonicalStep foreach (var p in Parts) p.WriteTo(w); w.EndArray(); w.EndObject(); + w.EndObject(); + } +} + +public sealed class ContinueWithStep : CanonicalStep +{ + public string StepName { get; } + public CanonicalExpr? WorkflowNameExpression { get; } + public CanonicalExpr? WorkflowVersionExpression { get; } + public CanonicalExpr? PayloadExpression { get; } + public CanonicalExpr? BusinessReferenceKeyExpression { get; } + public IReadOnlyList BusinessReferenceParts { get; } + + public ContinueWithStep( + string stepName, + CanonicalExpr? workflowNameExpression, + CanonicalExpr? workflowVersionExpression, + CanonicalExpr? payloadExpression, + CanonicalExpr? businessReferenceKeyExpression, + IReadOnlyList businessReferenceParts) + { + StepName = stepName; + WorkflowNameExpression = workflowNameExpression; + WorkflowVersionExpression = workflowVersionExpression; + PayloadExpression = payloadExpression; + BusinessReferenceKeyExpression = businessReferenceKeyExpression; + BusinessReferenceParts = businessReferenceParts; + } + + public override void WriteTo(CanonicalJsonWriter w) + { + w.BeginObject(); + w.Property("$type"); w.StringValue("continue-with-workflow"); + w.Property("stepName"); w.StringValue(StepName); + w.Property("invocation"); + w.BeginObject(); + if (WorkflowNameExpression is not null) + { + w.Property("workflowNameExpression"); WorkflowNameExpression.WriteTo(w); + } + if (WorkflowVersionExpression is not null) + { + w.Property("workflowVersionExpression"); WorkflowVersionExpression.WriteTo(w); + } + if (PayloadExpression is not null) + { + w.Property("payloadExpression"); PayloadExpression.WriteTo(w); + } + if (BusinessReferenceKeyExpression is not null || BusinessReferenceParts.Count > 0) + { + w.Property("businessReference"); + w.BeginObject(); + if (BusinessReferenceKeyExpression is not null) + { + w.Property("keyExpression"); BusinessReferenceKeyExpression.WriteTo(w); + } + w.Property("parts"); + w.BeginArray(); + foreach (var p in BusinessReferenceParts) p.WriteTo(w); + w.EndArray(); + w.EndObject(); + } + w.EndObject(); + w.EndObject(); + } +} + +public sealed class SubWorkflowStep : CanonicalStep +{ + public string StepName { get; } + public CanonicalExpr? WorkflowNameExpression { get; } + public CanonicalExpr? WorkflowVersionExpression { get; } + public CanonicalExpr? PayloadExpression { get; } + public CanonicalExpr? BusinessReferenceKeyExpression { get; } + public IReadOnlyList BusinessReferenceParts { get; } + public string? ResultKey { get; } + + public SubWorkflowStep( + string stepName, + CanonicalExpr? workflowNameExpression, + CanonicalExpr? workflowVersionExpression, + CanonicalExpr? payloadExpression, + CanonicalExpr? businessReferenceKeyExpression, + IReadOnlyList businessReferenceParts, + string? resultKey) + { + StepName = stepName; + WorkflowNameExpression = workflowNameExpression; + WorkflowVersionExpression = workflowVersionExpression; + PayloadExpression = payloadExpression; + BusinessReferenceKeyExpression = businessReferenceKeyExpression; + BusinessReferenceParts = businessReferenceParts; + ResultKey = resultKey; + } + + public override void WriteTo(CanonicalJsonWriter w) + { + w.BeginObject(); + w.Property("$type"); w.StringValue("sub-workflow"); + w.Property("stepName"); w.StringValue(StepName); + w.Property("invocation"); + w.BeginObject(); + if (WorkflowNameExpression is not null) + { + w.Property("workflowNameExpression"); WorkflowNameExpression.WriteTo(w); + } + if (WorkflowVersionExpression is not null) + { + w.Property("workflowVersionExpression"); WorkflowVersionExpression.WriteTo(w); + } + if (PayloadExpression is not null) + { + w.Property("payloadExpression"); PayloadExpression.WriteTo(w); + } + if (BusinessReferenceKeyExpression is not null || BusinessReferenceParts.Count > 0) + { + w.Property("businessReference"); + w.BeginObject(); + if (BusinessReferenceKeyExpression is not null) + { + w.Property("keyExpression"); BusinessReferenceKeyExpression.WriteTo(w); + } + w.Property("parts"); + w.BeginArray(); + foreach (var p in BusinessReferenceParts) p.WriteTo(w); + w.EndArray(); + w.EndObject(); + } + w.EndObject(); + if (ResultKey is not null) + { + w.Property("resultKey"); w.StringValue(ResultKey); + } + w.EndObject(); + } +} + +public sealed class ForkStep : CanonicalStep +{ + public string StepName { get; } + public IReadOnlyList Branches { get; } + + public ForkStep(string stepName, IReadOnlyList branches) + { + StepName = stepName; + Branches = branches; + } + + public override void WriteTo(CanonicalJsonWriter w) + { + w.BeginObject(); + w.Property("$type"); w.StringValue("fork"); + w.Property("stepName"); w.StringValue(StepName); + w.Property("branches"); + w.BeginArray(); + foreach (var branch in Branches) branch.WriteTo(w); + w.EndArray(); + w.EndObject(); } } diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalArtifactGenerator.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalArtifactGenerator.cs index c7f066451..703bb11bc 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalArtifactGenerator.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalArtifactGenerator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -29,10 +30,33 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { + // Canonical-fragment index: map from bare filename (e.g. "HomeWorkflow.get-offer.payload.json") + // to the file's text. Plugins that expose runtime-loaded JSON fragments register them as + // AdditionalFiles via the .targets file; this lets the generator resolve the + // Lazy(() => LoadFragment("X.json")) pattern at compile time instead of emitting + // WF020 "must be on WorkflowExpr" for the runtime loader. + var fragmentIndex = context.AdditionalTextsProvider + .Where(static t => t.Path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + .Collect() + .Select(static (files, ct) => + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var f in files) + { + var text = f.GetText(ct)?.ToString(); + if (text is null) continue; + var name = System.IO.Path.GetFileName(f.Path); + builder[name] = text; + } + return (IReadOnlyDictionary)builder.ToImmutable(); + }); + var workflowClasses = context.SyntaxProvider .CreateSyntaxProvider( predicate: static (node, _) => node is ClassDeclarationSyntax, - transform: static (ctx, _) => Transform(ctx)) + transform: static (ctx, _) => ctx) + .Combine(fragmentIndex) + .Select(static (tuple, _) => Transform(tuple.Left, tuple.Right)) .Where(static r => r is not null) .Select(static (r, _) => r!.Value); @@ -78,18 +102,151 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator private readonly struct GeneratorDiagnostic { - public GeneratorDiagnostic(DiagnosticDescriptor descriptor, string location, string message) + public GeneratorDiagnostic(DiagnosticDescriptor descriptor, Location? syntaxLocation, string message) { Descriptor = descriptor; - Location = location; + SyntaxLocation = syntaxLocation; Message = message; } public DiagnosticDescriptor Descriptor { get; } - public string Location { get; } + public Location? SyntaxLocation { get; } public string Message { get; } } - private static WorkflowCandidate? Transform(GeneratorSyntaxContext ctx) + /// + /// Context for inlining helper method bodies. Carries a mapping from + /// parameter names to the call-site argument expressions so that + /// can substitute them when + /// walking the helper's return expression. + /// + private sealed class HelperContext + { + public HelperContext( + Dictionary parameterMap, + SemanticModel callSiteModel, + string? paramsParameterName = null, + List? paramsArgs = null, + HelperContext? parent = null) + { + ParameterMap = parameterMap; + CallSiteModel = callSiteModel; + ParamsParameterName = paramsParameterName; + ParamsArgs = paramsArgs; + Parent = parent; + } + + public Dictionary ParameterMap { get; } + public SemanticModel CallSiteModel { get; } + /// Name of the params parameter, if any. + public string? ParamsParameterName { get; } + /// The call-site argument expressions for the params array. + public List? ParamsArgs { get; } + + /// + /// Enclosing helper context, if this one was created while inlining a nested helper. + /// When a parameter's mapped expression references identifiers in the parent scope + /// (e.g. BuildA(x) calls BuildB(WorkflowExpr.String(x)) — the inner + /// inliner substitutes B's param with an expression that still contains A's x), + /// the parent chain is how those references resolve. + /// + public HelperContext? Parent { get; } + + /// + /// Looks up in this context and walks up the parent + /// chain, chasing identifier-to-identifier forwardings. Example: helper A calls helper + /// B passing its own parameter k; B's paramMap maps its parameter to the + /// IdentifierNameSyntax k from A's scope. A single lookup from B would return + /// A's k identifier — still not useful. Chasing one more hop resolves to A's + /// call-site literal (or const field / non-identifier expression), which is what the + /// caller wants. Returns the most-resolved (expression, model) pair, or null if the + /// parameter name isn't found at any level. + /// + public (ExpressionSyntax Expression, SemanticModel Model)? ResolveParameter(string parameterName) + { + var name = parameterName; + var ctx = this; + (ExpressionSyntax Expression, SemanticModel Model)? lastMatch = null; + while (ctx is not null) + { + if (ctx.ParameterMap.TryGetValue(name, out var mapped)) + { + lastMatch = (mapped, ctx.CallSiteModel); + // If the mapped expression is a bare identifier, a parent context may + // have a more primitive value for the same name. Keep chasing. + if (mapped is IdentifierNameSyntax nextIdent && ctx.Parent is not null) + { + name = nextIdent.Identifier.ValueText; + ctx = ctx.Parent; + continue; + } + return lastMatch; + } + ctx = ctx.Parent; + } + return lastMatch; + } + } + + /// + /// Thread-scoped canonical-fragment index. Populated by for the + /// duration of a single workflow's walk, then cleared. Read by helpers that need to + /// resolve Lazy<T>(() => LoadFragment<T>(\"X.json\")).Value to the + /// pre-built canonical expression in the JSON file. Threading this via method parameters + /// would touch every call site in the generator; scoping via [ThreadStatic] keeps the + /// change surface small while remaining safe for Roslyn's concurrent per-class transform. + /// + [System.ThreadStatic] + private static IReadOnlyDictionary? t_fragmentIndex; + + private static WorkflowCandidate? Transform( + GeneratorSyntaxContext ctx, + IReadOnlyDictionary fragmentIndex) + { + var previous = t_fragmentIndex; + t_fragmentIndex = fragmentIndex; + try + { + return TransformCore(ctx, fragmentIndex); + } + catch (System.Exception ex) + { + // Defensive isolation: one crashing workflow class must not kill the whole + // generator pipeline (which would abort every other workflow in the same + // plugin with a CS8785). Instead emit the crash as a per-class WF020 + // warning and let EmitForWorkflow / EmitRegistry skip this sentinel. + var syntaxLocation = ctx.Node is ClassDeclarationSyntax c + ? c.Identifier.GetLocation() + : ctx.Node.GetLocation(); + var className = ctx.Node is ClassDeclarationSyntax cd + ? cd.Identifier.ValueText + : ""; + + var diag = new GeneratorDiagnostic( + WorkflowDiagnostics.GeneratorUnsupportedPattern, + syntaxLocation, + $"generator threw while processing '{className}': {ex.GetType().Name}: {ex.Message}. " + + "This workflow is skipped; other workflows in the plugin still bundle. " + + "Fix: file a P7 generator hardening item with the stack trace."); + + return new WorkflowCandidate( + className: "", // sentinel: EmitForWorkflow + EmitRegistry skip empty-class + workflowName: null, + workflowVersion: null, + displayName: null, + canonicalDefinitionJson: "", + contentHash: "", + diagnostics: ImmutableArray.Create(diag), + location: syntaxLocation?.ToString() ?? ""); + } + finally + { + t_fragmentIndex = previous; + } + } + + private static WorkflowCandidate? TransformCore( + GeneratorSyntaxContext ctx, + IReadOnlyDictionary fragmentIndex) { if (ctx.Node is not ClassDeclarationSyntax classDecl) { @@ -141,7 +298,7 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator { diagnostics.Add(new GeneratorDiagnostic( WorkflowDiagnostics.GeneratorUnsupportedPattern, - classDecl.Identifier.GetLocation().ToString(), + classDecl.Identifier.GetLocation(), "class implements IDeclarativeWorkflow but has no Spec property")); } @@ -314,6 +471,13 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator VisitExpr(abr.KeyExpression); foreach (var p in abr.Parts) VisitExpr(p.Expression); break; + case ContinueWithStep cw: + VisitExpr(cw.WorkflowNameExpression); + VisitExpr(cw.WorkflowVersionExpression); + VisitExpr(cw.PayloadExpression); + VisitExpr(cw.BusinessReferenceKeyExpression); + foreach (var p in cw.BusinessReferenceParts) VisitExpr(p.Expression); + break; } } @@ -488,7 +652,7 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator diagnostics.Add(new GeneratorDiagnostic( WorkflowDiagnostics.GeneratorUnsupportedPattern, - property.Locations.FirstOrDefault()?.ToString() ?? "", + property.Locations.FirstOrDefault(), $"property '{propertyName}' must be a string literal — non-literal initializers are not supported by the artifact generator")); return null; } @@ -521,8 +685,22 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator ExpressionSyntax expr, SemanticModel semanticModel, CanonicalDefinition definition, - ImmutableArray.Builder diagnostics) + ImmutableArray.Builder diagnostics, + HelperContext? helperContext = null) { + // Spec-level helper inlining: `Spec = SomeHelper.BuildSpec(args)` where the helper + // returns `WorkflowSpec.For()...Build()`. We recognise any invocation whose target + // method is declared in source, returns a WorkflowSpec, and has a single return + // statement — walk the helper body with a HelperContext mapping its parameters to the + // call-site arguments, then continue the spec walk on that inlined expression. + if (expr is InvocationExpressionSyntax helperInv && + TryInlineSpecHelper(helperInv, semanticModel, helperContext, diagnostics, + out var inlinedExpr, out var inlinedModel, out var inlinedContext)) + { + WalkSpecExpression(inlinedExpr, inlinedModel, definition, diagnostics, inlinedContext); + return; + } + // Recognise: WorkflowSpec.For().Build() // Recognise: WorkflowSpec.For().InitializeState().Build() // Unwind the chain from outermost (.Build() typically) to innermost (.For()). @@ -567,7 +745,7 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator diagnostics.Add(UnsupportedDiagnostic(inv, "InitializeState requires a WorkflowExpressionDefinition argument")); continue; } - var stateExpr = ParseCanonicalExpression(arg.Expression, semanticModel, diagnostics); + var stateExpr = ParseCanonicalExpression(arg.Expression, semanticModel, diagnostics, helperContext); if (stateExpr is not null) { definition.InitializeStateExpression = stateExpr; @@ -578,15 +756,35 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator var startArg = inv.ArgumentList.Arguments.FirstOrDefault()?.Expression; if (startArg is LambdaExpressionSyntax startLambda) { - var sequence = WalkFlowLambda(startLambda, semanticModel, diagnostics); + var sequence = WalkFlowLambda(startLambda, semanticModel, diagnostics, helperContext); if (sequence is not null) { definition.InitialSequence = sequence; } } + else if (startArg is not null) + { + // Method-group reference: resolve to method body and walk its statements. + var methodBody = TryResolveMethodBody(startArg, semanticModel); + if (methodBody is not null) + { + var bodyModel = methodBody.SyntaxTree == semanticModel.SyntaxTree + ? semanticModel + : semanticModel.Compilation.GetSemanticModel(methodBody.SyntaxTree); + var sequence = WalkFlowMethodBody(methodBody, bodyModel, diagnostics); + if (sequence is not null) + { + definition.InitialSequence = sequence; + } + } + else + { + diagnostics.Add(UnsupportedDiagnostic(inv, "StartWith: could not resolve method group to a method body")); + } + } else { - diagnostics.Add(UnsupportedDiagnostic(inv, "StartWith: only the Action> lambda overload is supported by the generator")); + diagnostics.Add(UnsupportedDiagnostic(inv, "StartWith requires a lambda or method group argument")); } break; } @@ -598,7 +796,43 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator diagnostics.Add(UnsupportedDiagnostic(inv, "AddTask requires a WorkflowHumanTaskDefinition argument")); continue; } - var task = WalkAddTaskChain(taskArg, semanticModel, diagnostics); + CanonicalTask? task = null; + + // Try helper-method resolution first (avoids false WF020 from + // WalkAddTaskChain hitting the helper-method name in its default case) + if (taskArg is InvocationExpressionSyntax taskInvocation) + { + var taskBody = TryResolveMethodBody(taskInvocation, semanticModel); + if (taskBody is not null) + { + var resolvedModel = taskBody.SyntaxTree == semanticModel.SyntaxTree + ? semanticModel + : semanticModel.Compilation.GetSemanticModel(taskBody.SyntaxTree); + + foreach (var stmt in taskBody.Statements) + { + if (stmt is ReturnStatementSyntax ret && ret.Expression is not null) + { + task = WalkAddTaskChain(ret.Expression, resolvedModel, diagnostics); + break; + } + } + } + } + + // Fall back to direct chain walking (for inline WorkflowHumanTask.For(...) chains) + task ??= WalkAddTaskChain(taskArg, semanticModel, diagnostics); + + if (task is null && taskArg is InvocationExpressionSyntax failedInv) + { + var methodName2 = failedInv.Expression switch + { + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + IdentifierNameSyntax id => id.Identifier.ValueText, + _ => failedInv.Expression.ToString(), + }; + diagnostics.Add(UnsupportedDiagnostic(inv, $"AddTask helper '{methodName2}' could not be resolved by the generator")); + } if (task is not null) { definition.Tasks.Add(task); @@ -615,7 +849,8 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator private static StepSequence? WalkFlowLambda( LambdaExpressionSyntax lambda, SemanticModel semanticModel, - ImmutableArray.Builder diagnostics) + ImmutableArray.Builder diagnostics, + HelperContext? helperContext = null) { // Lambdas come as `flow => flow.A().B()` (single expression) or // `flow => { flow.A(); flow.B(); }` (block). The expression-form @@ -636,7 +871,7 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator } var sequence = new StepSequence(); - WalkFlowChain(chain, semanticModel, sequence, diagnostics); + WalkFlowChain(chain, semanticModel, sequence, diagnostics, helperContext); return sequence; } @@ -644,7 +879,8 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator ExpressionSyntax chain, SemanticModel semanticModel, StepSequence sequence, - ImmutableArray.Builder diagnostics) + ImmutableArray.Builder diagnostics, + HelperContext? helperContext = null) { // Unwind the invocation chain from outermost (.Complete()) to the // innermost parameter reference, so we can walk root-first. @@ -672,7 +908,23 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator } var methodName = member.Name.Identifier.ValueText; var args = inv.ArgumentList.Arguments; - var step = ParseStep(inv, methodName, args, semanticModel, diagnostics); + + // Try inlining a custom fluent-helper method (extension or static-with-flow-param) + // before falling through to the built-in builder dispatch. This lets user-defined + // helpers like `.ApplyPolicyProductInfo(resultKey)` expand into the steps they + // would add at runtime, with proper argument substitution. + var beforeCount = diagnostics.Count; + if (TryInlineFluentHelper(inv, semanticModel, sequence, diagnostics, helperContext)) + { + continue; + } + // If the fluent-helper inliner failed with its own diagnostic, don't re-diagnose. + if (diagnostics.Count > beforeCount) + { + continue; + } + + var step = ParseStep(inv, methodName, args, semanticModel, diagnostics, helperContext); if (step is not null) { sequence.Steps.Add(step); @@ -685,7 +937,8 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator string methodName, SeparatedSyntaxList args, SemanticModel semanticModel, - ImmutableArray.Builder diagnostics) + ImmutableArray.Builder diagnostics, + HelperContext? helperContext = null) { switch (methodName) { @@ -697,15 +950,32 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator diagnostics.Add(UnsupportedDiagnostic(inv, $"{methodName}(key, expr) requires 2 arguments")); return null; } - if (args[0].Expression is not LiteralExpressionSyntax keyLit || - !keyLit.IsKind(SyntaxKind.StringLiteralExpression)) + // The key must resolve to a compile-time string: a direct literal, a const + // field, or a helper parameter whose call-site argument is one of those. + string? stateKey = null; + if (args[0].Expression is LiteralExpressionSyntax keyLit && + keyLit.IsKind(SyntaxKind.StringLiteralExpression)) { - diagnostics.Add(UnsupportedDiagnostic(inv, $"{methodName} key must be a string literal")); + stateKey = keyLit.Token.ValueText; + } + else + { + stateKey = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (stateKey is null && helperContext is not null && + args[0].Expression is IdentifierNameSyntax keyParam && + helperContext.ResolveParameter(keyParam.Identifier.ValueText) is { } keyResolution) + { + stateKey = TryResolveStringLiteral(keyResolution.Expression, keyResolution.Model); + } + } + if (stateKey is null) + { + diagnostics.Add(UnsupportedDiagnostic(inv, $"{methodName} key must be a string literal or a helper parameter that maps to one")); return null; } - var valueExpr = ParseCanonicalExpression(args[1].Expression, semanticModel, diagnostics); + var valueExpr = ParseCanonicalExpression(args[1].Expression, semanticModel, diagnostics, helperContext); if (valueExpr is null) return null; - return new SetStateStep(keyLit.Token.ValueText, valueExpr, onlyIfPresent: methodName == "SetIfHasValue"); + return new SetStateStep(stateKey, valueExpr, onlyIfPresent: methodName == "SetIfHasValue"); } case "Complete": return new CompleteStep(); @@ -716,13 +986,13 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator diagnostics.Add(UnsupportedDiagnostic(inv, "ActivateTask requires a task name argument")); return null; } - if (args[0].Expression is not LiteralExpressionSyntax nameLit || - !nameLit.IsKind(SyntaxKind.StringLiteralExpression)) + var activateTaskName = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (activateTaskName is null) { - diagnostics.Add(UnsupportedDiagnostic(inv, "ActivateTask name must be a string literal")); + diagnostics.Add(UnsupportedDiagnostic(inv, "ActivateTask name must be a string literal or const field")); return null; } - return new ActivateTaskStep(nameLit.Token.ValueText); + return new ActivateTaskStep(activateTaskName); } case "WhenExpression": { @@ -737,7 +1007,7 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator diagnostics.Add(UnsupportedDiagnostic(inv, "WhenExpression decisionName must be a string literal")); return null; } - var cond = ParseCanonicalExpression(args[1].Expression, semanticModel, diagnostics); + var cond = ParseCanonicalExpression(args[1].Expression, semanticModel, diagnostics, helperContext); if (cond is null) return null; var whenTrueLambda = args[2].Expression as LambdaExpressionSyntax; if (whenTrueLambda is null) @@ -745,11 +1015,11 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator diagnostics.Add(UnsupportedDiagnostic(inv, "WhenExpression whenTrue must be an Action> lambda")); return null; } - var whenTrueSeq = WalkFlowLambda(whenTrueLambda, semanticModel, diagnostics) ?? new StepSequence(); + var whenTrueSeq = WalkFlowLambda(whenTrueLambda, semanticModel, diagnostics, helperContext) ?? new StepSequence(); var whenElseSeq = new StepSequence(); if (args.Count >= 4 && args[3].Expression is LambdaExpressionSyntax whenElseLambda) { - whenElseSeq = WalkFlowLambda(whenElseLambda, semanticModel, diagnostics) ?? new StepSequence(); + whenElseSeq = WalkFlowLambda(whenElseLambda, semanticModel, diagnostics, helperContext) ?? new StepSequence(); } return new DecisionStep(declLit.Token.ValueText, cond, whenTrueSeq, whenElseSeq); } @@ -785,11 +1055,24 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator } else if (argName == "whenFailure" && expr is LambdaExpressionSyntax fl) { - whenFailure = WalkFlowLambda(fl, semanticModel, diagnostics); + whenFailure = WalkFlowLambda(fl, semanticModel, diagnostics, helperContext); } else if (argName == "whenTimeout" && expr is LambdaExpressionSyntax tl) { - whenTimeout = WalkFlowLambda(tl, semanticModel, diagnostics); + whenTimeout = WalkFlowLambda(tl, semanticModel, diagnostics, helperContext); + } + else if ((argName == "onFailure" || (argName is null && whenFailure is null && payload is not null)) && + TryDesugarHandledBranchAction(expr, semanticModel) is { } onFailSeq) + { + // Enum shortcut: `onFailure: WorkflowHandledBranchAction.Complete` (or positional + // after payload) desugars to a single-Complete branch. Lambdas take precedence + // because the lambda branch above matches first. + whenFailure = onFailSeq; + } + else if ((argName == "onTimeout" || (argName is null && whenFailure is not null && whenTimeout is null)) && + TryDesugarHandledBranchAction(expr, semanticModel) is { } onTimeoutSeq) + { + whenTimeout = onTimeoutSeq; } else if (argName == "timeoutSeconds" && expr is LiteralExpressionSyntax tsLit && tsLit.IsKind(SyntaxKind.NumericLiteralExpression)) { @@ -801,9 +1084,35 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator else if (argName is null && payload is null) { // Positional after address = payload expression - payload = ParseCanonicalExpression(expr, semanticModel, diagnostics); + payload = ParseCanonicalExpression(expr, semanticModel, diagnostics, helperContext); if (payload is null) return null; } + else if (argName is null && expr is LambdaExpressionSyntax positionalLambda) + { + // Positional lambda after payload — whenFailure or whenTimeout + // based on parameter order: Call(name, addr, payload, whenFailure, ...) + if (whenFailure is null) + { + whenFailure = WalkFlowLambda(positionalLambda, semanticModel, diagnostics, helperContext); + } + else if (whenTimeout is null) + { + whenTimeout = WalkFlowLambda(positionalLambda, semanticModel, diagnostics, helperContext); + } + } + else if (argName is null && resultKey is null) + { + // Positional string after lambdas/enums — resultKey + var rk = TryResolveStringLiteral(expr, semanticModel); + if (rk is not null) + { + resultKey = rk; + } + else + { + diagnostics.Add(UnsupportedDiagnostic(a, $"Call argument '' is not supported by the generator")); + } + } else { diagnostics.Add(UnsupportedDiagnostic(a, $"Call argument '{argName ?? ""}' is not supported by the generator")); @@ -818,12 +1127,1611 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator whenFailure, whenTimeout); } + case "WhenStateFlag": + { + // WhenStateFlag(stateKey, expectedValue, decisionName, whenTrue, whenElse) + // Desugars to: WhenExpression(decisionName, Eq(Path("state.{stateKey}"), Bool(expectedValue)), ...) + if (args.Count < 4) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "WhenStateFlag requires stateKey, expectedValue, decisionName, whenTrue")); + return null; + } + var stateKey = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (stateKey is null) { diagnostics.Add(UnsupportedDiagnostic(inv, "WhenStateFlag stateKey must be a string literal")); return null; } + + bool expectedValue; + if (args[1].Expression is LiteralExpressionSyntax boolLit && boolLit.IsKind(SyntaxKind.TrueLiteralExpression)) + expectedValue = true; + else if (args[1].Expression is LiteralExpressionSyntax falseLit && falseLit.IsKind(SyntaxKind.FalseLiteralExpression)) + expectedValue = false; + else { diagnostics.Add(UnsupportedDiagnostic(inv, "WhenStateFlag expectedValue must be true or false")); return null; } + + var decisionName = TryResolveStringLiteral(args[2].Expression, semanticModel); + if (decisionName is null) { diagnostics.Add(UnsupportedDiagnostic(inv, "WhenStateFlag decisionName must be a string literal")); return null; } + + // Synthesize condition: eq(path("state.{stateKey}"), bool(expectedValue)) + var condition = new BinaryExpr("eq", new PathExpr($"state.{stateKey}"), new BoolExpr(expectedValue)); + + var whenTrue = args[3].Expression is LambdaExpressionSyntax trueLambda + ? WalkFlowLambda(trueLambda, semanticModel, diagnostics, helperContext) ?? new StepSequence() + : new StepSequence(); + var whenElse = args.Count >= 5 && args[4].Expression is LambdaExpressionSyntax elseLambda + ? WalkFlowLambda(elseLambda, semanticModel, diagnostics, helperContext) ?? new StepSequence() + : new StepSequence(); + + return new DecisionStep(decisionName, condition, whenTrue, whenElse); + } + case "ContinueWith": + { + if (args.Count < 2) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "ContinueWith requires stepName and invocationDeclaration")); + return null; + } + var cwStepName = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (cwStepName is null) { diagnostics.Add(UnsupportedDiagnostic(inv, "ContinueWith stepName must be a string literal")); return null; } + + var invDecl = ParseInvocationDeclaration(args[1].Expression, semanticModel, diagnostics, helperContext: helperContext); + if (invDecl is null) return null; + return invDecl.Value.ToContinueWithStep(cwStepName); + } + case "SetBusinessReference": + { + if (args.Count < 1) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "SetBusinessReference requires a WorkflowBusinessReferenceDeclaration argument")); + return null; + } + var brDecl = ParseBusinessReferenceDeclaration(args[0].Expression, semanticModel, diagnostics, helperContext); + if (brDecl is null) return null; + return brDecl; + } + case "WhenStateEquals": + case "WhenPayloadEquals": + return ParseWhenValueEquals(inv, methodName, args, semanticModel, diagnostics, helperContext); + case "SubWorkflow": + return ParseSubWorkflowStep(inv, args, semanticModel, diagnostics, helperContext); + case "Fork": + return ParseForkStep(inv, args, semanticModel, diagnostics, helperContext); default: diagnostics.Add(UnsupportedDiagnostic(inv, $"WorkflowFlowBuilder.{methodName} is not yet supported by the generator")); return null; } } + /// + /// Desugars WhenStateEquals(key, value, decisionName, whenTrue, whenElse?) or + /// WhenPayloadEquals(...) into a with the condition + /// eq(path("state.{key}" | "payload.{key}"), <value-literal>). + /// selects the path prefix. + /// + private static CanonicalStep? ParseWhenValueEquals( + InvocationExpressionSyntax inv, + string methodName, + SeparatedSyntaxList args, + SemanticModel semanticModel, + ImmutableArray.Builder diagnostics, + HelperContext? helperContext) + { + if (args.Count < 4) + { + diagnostics.Add(UnsupportedDiagnostic(inv, $"{methodName} requires key, expectedValue, decisionName, whenTrue")); + return null; + } + + var key = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (key is null) + { + diagnostics.Add(UnsupportedDiagnostic(inv, $"{methodName} key must be a string literal")); + return null; + } + + var expectedValue = TryParseConstantExpression(args[1].Expression, semanticModel); + if (expectedValue is null) + { + diagnostics.Add(UnsupportedDiagnostic(args[1], $"{methodName} expectedValue must be a string / numeric / boolean / enum literal")); + return null; + } + + var decisionName = TryResolveStringLiteral(args[2].Expression, semanticModel); + if (decisionName is null) + { + diagnostics.Add(UnsupportedDiagnostic(inv, $"{methodName} decisionName must be a string literal")); + return null; + } + + var pathPrefix = methodName == "WhenStateEquals" ? "state" : "payload"; + var condition = new BinaryExpr("eq", new PathExpr($"{pathPrefix}.{key}"), expectedValue); + + var whenTrue = args[3].Expression is LambdaExpressionSyntax trueLambda + ? WalkFlowLambda(trueLambda, semanticModel, diagnostics, helperContext) ?? new StepSequence() + : new StepSequence(); + var whenElse = args.Count >= 5 && args[4].Expression is LambdaExpressionSyntax elseLambda + ? WalkFlowLambda(elseLambda, semanticModel, diagnostics, helperContext) ?? new StepSequence() + : new StepSequence(); + + return new DecisionStep(decisionName, condition, whenTrue, whenElse); + } + + /// + /// Handles the canonicalizable overloads of SubWorkflow: + /// + /// SubWorkflow(stepName, WorkflowReference, string? resultKey) + /// SubWorkflow(stepName, WorkflowWorkflowInvocationDeclaration, string? resultKey) + /// SubWorkflow(stepName, WorkflowReference, WorkflowExpressionDefinition payload, + /// WorkflowBusinessReferenceDeclaration? businessRef, string? resultKey) + /// + /// The Func<Context, ...> factory overloads are runtime-only and can't be canonicalized; they emit a diagnostic. + /// + private static CanonicalStep? ParseSubWorkflowStep( + InvocationExpressionSyntax inv, + SeparatedSyntaxList args, + SemanticModel semanticModel, + ImmutableArray.Builder diagnostics, + HelperContext? helperContext) + { + if (args.Count < 2) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "SubWorkflow requires stepName and workflow reference or invocation declaration")); + return null; + } + + var stepName = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (stepName is null) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "SubWorkflow stepName must be a string literal")); + return null; + } + + // Resolve the invocation: second arg is either a WorkflowReference (resolve to name + // via field initializer) or a WorkflowWorkflowInvocationDeclaration. Shared with ContinueWith. + var invTypeInfo = semanticModel.GetTypeInfo(args[1].Expression); + var invTypeName = invTypeInfo.Type?.Name; + + CanonicalExpr? workflowNameExpr = null; + CanonicalExpr? workflowVersionExpr = null; + CanonicalExpr? payloadExpr = null; + CanonicalExpr? brKeyExpr = null; + IReadOnlyList brParts = new List(); + + if (invTypeName == "WorkflowWorkflowInvocationDeclaration") + { + var parsed = ParseInvocationDeclaration(args[1].Expression, semanticModel, diagnostics, helperContext: helperContext); + if (parsed is null) return null; + workflowNameExpr = parsed.Value.WorkflowNameExpression; + workflowVersionExpr = parsed.Value.WorkflowVersionExpression; + payloadExpr = parsed.Value.PayloadExpression; + brKeyExpr = parsed.Value.BusinessReferenceKeyExpression; + brParts = parsed.Value.BusinessReferenceParts; + } + else + { + // Assume WorkflowReference — resolve to name via the field's constructor literal. + var workflowName = ResolveWorkflowReferenceFieldName(args[1].Expression, semanticModel); + if (workflowName is null) + { + diagnostics.Add(UnsupportedDiagnostic(args[1], "SubWorkflow second argument could not be resolved to a WorkflowReference with a literal workflow name")); + return null; + } + workflowNameExpr = new StringExpr(workflowName); + } + + // Remaining args can be: WorkflowExpressionDefinition payload, WorkflowBusinessReferenceDeclaration br, + // string? resultKey, or Func factories (unsupported). + string? resultKey = null; + for (var i = 2; i < args.Count; i++) + { + var arg = args[i]; + var argName = arg.NameColon?.Name.Identifier.ValueText; + var expr = arg.Expression; + + if (argName == "resultKey" || (argName is null && expr is LiteralExpressionSyntax slit && slit.IsKind(SyntaxKind.StringLiteralExpression))) + { + var rk = TryResolveStringLiteral(expr, semanticModel); + if (rk is not null) { resultKey = rk; continue; } + } + + var exprTypeName = semanticModel.GetTypeInfo(expr).Type?.Name; + if (exprTypeName is not null && exprTypeName.StartsWith("Workflow", StringComparison.Ordinal) && exprTypeName.EndsWith("ExpressionDefinition", StringComparison.Ordinal)) + { + // Payload: any WorkflowExpressionDefinition subtype (WorkflowObjectExpressionDefinition, etc.) + payloadExpr = ParseCanonicalExpression(expr, semanticModel, diagnostics, helperContext); + if (payloadExpr is null) return null; + continue; + } + + if (exprTypeName == "WorkflowBusinessReferenceDeclaration") + { + var br = ParseBusinessReferenceDeclaration(expr, semanticModel, diagnostics, helperContext); + if (br is not null) + { + brKeyExpr = br.KeyExpression; + brParts = br.Parts; + } + continue; + } + + // Lambda or Func arg → runtime-only overload, not canonicalizable. + if (expr is LambdaExpressionSyntax) + { + diagnostics.Add(UnsupportedDiagnostic(arg, "SubWorkflow with Func factory cannot be canonicalized; pass a WorkflowExpressionDefinition payload or a WorkflowWorkflowInvocationDeclaration instead")); + return null; + } + + diagnostics.Add(UnsupportedDiagnostic(arg, $"SubWorkflow argument '{argName ?? ""}' is not supported by the generator")); + } + + return new SubWorkflowStep(stepName, workflowNameExpr, workflowVersionExpr, payloadExpr, brKeyExpr, brParts, resultKey); + } + + /// + /// Handles Fork(stepName, branch1, branch2, ...). Each branch is an Action<WorkflowFlowBuilder<T>> + /// lambda walked into a . + /// + private static CanonicalStep? ParseForkStep( + InvocationExpressionSyntax inv, + SeparatedSyntaxList args, + SemanticModel semanticModel, + ImmutableArray.Builder diagnostics, + HelperContext? helperContext) + { + if (args.Count < 2) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "Fork requires stepName and at least one branch lambda")); + return null; + } + + var stepName = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (stepName is null) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "Fork stepName must be a string literal")); + return null; + } + + var branches = new List(); + for (var i = 1; i < args.Count; i++) + { + if (args[i].Expression is not LambdaExpressionSyntax branchLambda) + { + diagnostics.Add(UnsupportedDiagnostic(args[i], "Fork branch must be an Action> lambda")); + return null; + } + var seq = WalkFlowLambda(branchLambda, semanticModel, diagnostics, helperContext) ?? new StepSequence(); + branches.Add(seq); + } + + return new ForkStep(stepName, branches); + } + + /// + /// Desugars WorkflowHandledBranchAction.Complete into a single-step + /// containing a . WorkflowHandledBranchAction.None (and any + /// other shape) returns null — the caller should fall through to lambda/default handling. + /// + private static StepSequence? TryDesugarHandledBranchAction(ExpressionSyntax expr, SemanticModel semanticModel) + { + // Expected syntax: `WorkflowHandledBranchAction.Complete` — a simple member access. + if (expr is not MemberAccessExpressionSyntax ma) return null; + + var typeName = semanticModel.GetTypeInfo(expr).Type?.Name; + if (typeName != "WorkflowHandledBranchAction") return null; + + if (ma.Name.Identifier.ValueText == "Complete") + { + var seq = new StepSequence(); + seq.Steps.Add(new CompleteStep()); + return seq; + } + // `None` and any other value → no branch sequence. + return null; + } + + /// + /// Resolves a direct SomeLoader.LoadFragment<T>("name.json") call (without the + /// Lazy<T> wrapper). Uses the same thread-scoped fragment index that + /// reads from; returns the parsed fragment or + /// null when the shape doesn't match. + /// + private static CanonicalExpr? TryResolveDirectFragmentCall( + InvocationExpressionSyntax invocation, + SemanticModel callSiteModel, + ImmutableArray.Builder diagnostics) + { + var methodName = invocation.Expression switch + { + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + IdentifierNameSyntax id => id.Identifier.ValueText, + _ => null, + }; + if (methodName != "LoadFragment") return null; + if (invocation.ArgumentList.Arguments.Count < 1) return null; + + // The method must actually be a canonical-fragment loader, not an unrelated overload + // called LoadFragment. Two accepted shapes: + // (a) non-generic returning a trusted WorkflowExpressionDefinition, or + // (b) generic `TValue LoadFragment(string, ...)` where the caller closed + // TValue to a trusted Workflow contract. For (b) the method's ReturnType is the + // type parameter itself, so we look at TypeArguments instead. + var symbolInfo = callSiteModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is not IMethodSymbol method) return null; + + INamedTypeSymbol? effectiveReturn = null; + if (method.ReturnType is INamedTypeSymbol namedReturn) + { + effectiveReturn = namedReturn; + } + else if (method.ReturnType is ITypeParameterSymbol typeParam) + { + // Map the return-type parameter to the method's corresponding closed type argument. + // For `TValue LoadFragment(...)` called as `LoadFragment(...)`, + // TypeParameters[0] == typeParam and TypeArguments[0] == WorkflowExpressionDefinition. + for (var i = 0; i < method.TypeParameters.Length; i++) + { + if (SymbolEqualityComparer.Default.Equals(method.TypeParameters[i], typeParam) && + i < method.TypeArguments.Length) + { + effectiveReturn = method.TypeArguments[i] as INamedTypeSymbol; + break; + } + } + } + if (effectiveReturn is null || + !WorkflowWellKnownTypes.IsTrustedAssembly(effectiveReturn.ContainingAssembly)) + { + return null; + } + + var fragmentName = TryResolveStringLiteral(invocation.ArgumentList.Arguments[0].Expression, callSiteModel); + if (fragmentName is null) return null; + + var index = t_fragmentIndex; + if (index is null || index.Count == 0) return null; + + var lookupName = System.IO.Path.GetFileName(fragmentName); + if (!index.TryGetValue(lookupName, out var json)) + { + diagnostics.Add(UnsupportedDiagnostic(invocation, + $"canonical fragment '{fragmentName}' not found in AdditionalFiles. " + + $"Expected a file named '{lookupName}' under the plugin's CanonicalTemplates directory.")); + return null; + } + + try + { + return CanonicalJsonFragmentParser.Parse(json); + } + catch (FragmentParseException ex) + { + diagnostics.Add(UnsupportedDiagnostic(invocation, + $"canonical fragment '{fragmentName}' is not valid canonical JSON: {ex.Message}")); + return null; + } + } + + /// + /// Resolves a SomeField.Value expression where SomeField is a + /// static readonly Lazy<T> initialised with () => LoadFragment<T>("name.json"). + /// Reads the embedded-resource JSON from the thread-scoped fragment index (populated by the + /// generator's AdditionalTextsProvider), parses it with , + /// and returns the inlined . Returns null when the shape doesn't + /// match, the fragment index is empty, or the file couldn't be found — the caller falls through + /// to the usual "must be on WorkflowExpr" diagnostic. + /// + private static CanonicalExpr? TryResolveLazyFragmentValue( + MemberAccessExpressionSyntax valueAccess, + SemanticModel callSiteModel, + ImmutableArray.Builder diagnostics) + { + if (valueAccess.Name.Identifier.ValueText != "Value") return null; + + // The receiver (SomeField or Class.SomeField) must resolve to a Lazy field. + var symbolInfo = callSiteModel.GetSymbolInfo(valueAccess.Expression); + if (symbolInfo.Symbol is not IFieldSymbol fieldSymbol) return null; + if (fieldSymbol.Type is not INamedTypeSymbol fieldType || + fieldType.Name != "Lazy" || + fieldType.TypeArguments.Length != 1) + { + return null; + } + + // Walk the field's declaration syntax to find the initializer lambda. + foreach (var syntaxRef in fieldSymbol.DeclaringSyntaxReferences) + { + if (syntaxRef.GetSyntax() is not VariableDeclaratorSyntax varDecl || + varDecl.Initializer?.Value is null) + { + continue; + } + + // Recognise `new Lazy(() => LoadFragment("X.json"))` and the target-typed + // shorthand `new(() => LoadFragment("X.json"))`. + ArgumentListSyntax? ctorArgs = varDecl.Initializer.Value switch + { + ObjectCreationExpressionSyntax oc => oc.ArgumentList, + ImplicitObjectCreationExpressionSyntax ioc => ioc.ArgumentList, + _ => null, + }; + if (ctorArgs is null || ctorArgs.Arguments.Count == 0) continue; + + var factoryArg = ctorArgs.Arguments[0].Expression; + if (factoryArg is not LambdaExpressionSyntax lambda) continue; + + // Lambda body is the LoadFragment call. Expression-body: `() => LoadFragment(...)`. + ExpressionSyntax? lambdaExpr = lambda switch + { + SimpleLambdaExpressionSyntax sl => sl.ExpressionBody, + ParenthesizedLambdaExpressionSyntax pl => pl.ExpressionBody, + _ => null, + }; + if (lambdaExpr is not InvocationExpressionSyntax loadInv) continue; + + // The called method's simple name must be LoadFragment. + var loadMemberName = loadInv.Expression switch + { + IdentifierNameSyntax ident => ident.Identifier.ValueText, + GenericNameSyntax generic => generic.Identifier.ValueText, + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + _ => null, + }; + if (loadMemberName != "LoadFragment") continue; + + if (loadInv.ArgumentList.Arguments.Count < 1) continue; + var pathExpr = loadInv.ArgumentList.Arguments[0].Expression; + var fragmentName = TryResolveStringLiteral(pathExpr, callSiteModel); + if (fragmentName is null) continue; + + var index = t_fragmentIndex; + if (index is null || index.Count == 0) + { + diagnostics.Add(UnsupportedDiagnostic(valueAccess, + $"canonical fragment '{fragmentName}' cannot be resolved: the AdditionalFiles index is empty. " + + "Check that the plugin's CanonicalTemplates/*.json files are exposed via .")); + return null; + } + + // The LoadFragment argument can be either a bare filename (`foo.json`) or a + // dotted logical name whose tail is the filename. Use the basename for lookup. + var lookupName = System.IO.Path.GetFileName(fragmentName); + if (!index.TryGetValue(lookupName, out var json)) + { + diagnostics.Add(UnsupportedDiagnostic(valueAccess, + $"canonical fragment '{fragmentName}' not found in AdditionalFiles. " + + $"Expected a file named '{lookupName}' under the plugin's CanonicalTemplates directory.")); + return null; + } + + try + { + return CanonicalJsonFragmentParser.Parse(json); + } + catch (FragmentParseException ex) + { + diagnostics.Add(UnsupportedDiagnostic(valueAccess, + $"canonical fragment '{fragmentName}' is not valid canonical JSON: {ex.Message}")); + return null; + } + } + + return null; + } + + /// + /// Spec-level helper inlining: recognises Spec = Helper.BuildSpec(args); where the + /// helper returns WorkflowSpec<T>. Walks the helper body to find its single + /// return statement (expression body or block with one return), maps helper parameters to + /// call-site arguments via a , and hands the inlined expression + /// back to so the generator sees a normal fluent chain + /// WorkflowSpec.For<T>()...Build(). + /// + private static bool TryInlineSpecHelper( + InvocationExpressionSyntax invocation, + SemanticModel callSiteModel, + HelperContext? outerContext, + ImmutableArray.Builder diagnostics, + out ExpressionSyntax inlinedExpression, + out SemanticModel inlinedModel, + out HelperContext inlinedContext) + { + inlinedExpression = null!; + inlinedModel = null!; + inlinedContext = null!; + + var symbolInfo = callSiteModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is not IMethodSymbol method) return false; + + // Return type must be WorkflowSpec from a trusted workflow assembly. + if (method.ReturnType is not INamedTypeSymbol returnType || + returnType.Name != "WorkflowSpec" || + !WorkflowWellKnownTypes.IsTrustedAssembly(returnType.ContainingAssembly)) + { + return false; + } + + // Don't recurse into built-in WorkflowSpec static factory methods (WorkflowSpec.For). + if (method.ContainingType is INamedTypeSymbol containerType && + containerType.Name == "WorkflowSpec" && + WorkflowWellKnownTypes.IsTrustedAssembly(containerType.ContainingAssembly)) + { + return false; + } + + // Must be declared in source. + if (method.DeclaringSyntaxReferences.Length == 0) return false; + var syntaxRef = method.DeclaringSyntaxReferences[0]; + if (syntaxRef.GetSyntax() is not MethodDeclarationSyntax methodSyntax) return false; + + // Extract the single return expression (expression body or block with one return). + ExpressionSyntax? returnExpr = methodSyntax.ExpressionBody?.Expression; + if (returnExpr is null && methodSyntax.Body is { } block) + { + ReturnStatementSyntax? returnStmt = null; + foreach (var stmt in block.Statements) + { + if (stmt is ReturnStatementSyntax r && r.Expression is not null) + { + if (returnStmt is not null) return false; // multiple returns — bail + returnStmt = r; + } + } + returnExpr = returnStmt?.Expression; + } + if (returnExpr is null) return false; + + // Map helper parameters to call-site arguments. Spec-level helpers are normally static + // (not extension methods) and take plain parameters. + var paramMap = new Dictionary(); + var args = invocation.ArgumentList.Arguments; + for (var i = 0; i < method.Parameters.Length; i++) + { + var param = method.Parameters[i]; + if (i < args.Count) + { + paramMap[param.Name] = args[i].Expression; + } + else if (param.DeclaringSyntaxReferences.Length > 0 && + param.DeclaringSyntaxReferences[0].GetSyntax() is ParameterSyntax paramSyntax && + paramSyntax.Default?.Value is not null) + { + paramMap[param.Name] = paramSyntax.Default.Value; + } + } + + var methodTree = syntaxRef.SyntaxTree; + inlinedModel = methodTree == callSiteModel.SyntaxTree + ? callSiteModel + : callSiteModel.Compilation.GetSemanticModel(methodTree); + inlinedContext = new HelperContext(paramMap, callSiteModel, parent: outerContext); + inlinedExpression = returnExpr; + return true; + } + + /// + /// Attempts to inline a user-defined fluent helper method whose shape is + /// WorkflowFlowBuilder<T> Name(this WorkflowFlowBuilder<T> flow, ...) + /// (or the equivalent non-extension signature with a flow parameter) into the surrounding + /// step sequence. Each .Set() / .Call() / .WhenExpression() invocation + /// found inside the helper body is parsed as a step with a that + /// substitutes the helper's parameters with call-site arguments. + /// + /// Supports: + /// + /// Expression-bodied helpers returning a fluent chain on the flow parameter. + /// Block bodies containing fluent chain statements, if (cond) ... statements + /// where cond resolves to a compile-time bool via the helper context, and a + /// terminating return flow; or return flow.X(...)...;. + /// Recursive composition — one helper calling another helper. + /// + /// Returns true if the invocation was inlined (steps appended to ). + /// Returns false if the method is not a fluent helper (no diagnostic emitted — falls through + /// to the built-in dispatch). Emits a diagnostic and returns false + /// when the method matches the shape but the body contains an unsupported construct. + /// + private static bool TryInlineFluentHelper( + InvocationExpressionSyntax invocation, + SemanticModel callSiteModel, + StepSequence sequence, + ImmutableArray.Builder diagnostics, + HelperContext? outerContext) + { + var symbolInfo = callSiteModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is not IMethodSymbol method) return false; + + // When the user writes `flow.Helper(args)`, Roslyn returns the REDUCED form of the + // extension method — `Parameters` excludes the `this` parameter. For analysing the + // declared signature we need the unreduced form (`ReducedFrom`), while call-site args + // still correspond to the reduced signature. Normalise by always working with the full + // declared signature; the flag tells us whether to skip the first param when mapping + // call-site arguments. + var declaredMethod = method.ReducedFrom ?? method; + var isReducedExtension = method.ReducedFrom is not null; + + // Return type must be WorkflowFlowBuilder from a trusted workflow assembly. + if (declaredMethod.ReturnType is not INamedTypeSymbol returnType || + returnType.Name != "WorkflowFlowBuilder" || + !WorkflowWellKnownTypes.IsTrustedAssembly(returnType.ContainingAssembly)) + { + return false; + } + + // Built-in WorkflowFlowBuilder methods (Set, Call, ActivateTask, etc.) live in a trusted + // assembly and also return WorkflowFlowBuilder. They must NOT be treated as fluent + // helpers — they're dispatched by ParseStep. + if (declaredMethod.ContainingType is INamedTypeSymbol containerType && + containerType.Name == "WorkflowFlowBuilder" && + WorkflowWellKnownTypes.IsTrustedAssembly(containerType.ContainingAssembly)) + { + return false; + } + + // Find the flow parameter (extension `this` or a positional WorkflowFlowBuilder param). + string? flowParamName = null; + int flowParamIndex = -1; + for (var i = 0; i < declaredMethod.Parameters.Length; i++) + { + if (declaredMethod.Parameters[i].Type is INamedTypeSymbol paramType && + paramType.Name == "WorkflowFlowBuilder" && + WorkflowWellKnownTypes.IsTrustedAssembly(paramType.ContainingAssembly)) + { + flowParamName = declaredMethod.Parameters[i].Name; + flowParamIndex = i; + break; + } + } + if (flowParamName is null) return false; + + // Must be declared in source. + if (declaredMethod.DeclaringSyntaxReferences.Length == 0) return false; + var syntaxRef = declaredMethod.DeclaringSyntaxReferences[0]; + if (syntaxRef.GetSyntax() is not MethodDeclarationSyntax methodSyntax) return false; + + // Build paramMap: helper params → call-site args, skipping the flow param. + var paramMap = new Dictionary(); + var args = invocation.ArgumentList.Arguments; + // In reduced extension form, call-site args omit the `this` receiver, so param[flowIdx+1] + // aligns with args[0]. For static call syntax `Helper.Fn(flow, a, b)` the flow param IS + // in args, so no offset is needed beyond skipping it in the param loop. + var argOffset = isReducedExtension ? 1 : 0; + for (var i = 0; i < declaredMethod.Parameters.Length; i++) + { + if (i == flowParamIndex) continue; + var param = declaredMethod.Parameters[i]; + var argIndex = i - argOffset; + if (argIndex >= 0 && argIndex < args.Count) + { + paramMap[param.Name] = args[argIndex].Expression; + } + else if (param.DeclaringSyntaxReferences.Length > 0 && + param.DeclaringSyntaxReferences[0].GetSyntax() is ParameterSyntax paramSyntax && + paramSyntax.Default?.Value is not null) + { + paramMap[param.Name] = paramSyntax.Default.Value; + } + } + + var methodTree = syntaxRef.SyntaxTree; + var methodModel = methodTree == callSiteModel.SyntaxTree + ? callSiteModel + : callSiteModel.Compilation.GetSemanticModel(methodTree); + var helperCtx = new HelperContext(paramMap, callSiteModel, parent: outerContext); + + if (methodSyntax.ExpressionBody?.Expression is { } exprBody) + { + return WalkFluentHelperExpression(exprBody, flowParamName, methodModel, sequence, diagnostics, helperCtx); + } + if (methodSyntax.Body is { } block) + { + foreach (var stmt in block.Statements) + { + if (!WalkFluentHelperStatement(stmt, flowParamName, methodModel, sequence, diagnostics, helperCtx)) + { + return false; + } + } + return true; + } + return false; + } + + /// + /// Walks an expression that is either flow (no-op) or a fluent chain rooted at the + /// helper's flow parameter. Each call in the chain becomes a step via recursive + /// or dispatch. + /// + private static bool WalkFluentHelperExpression( + ExpressionSyntax expr, + string flowParamName, + SemanticModel model, + StepSequence sequence, + ImmutableArray.Builder diagnostics, + HelperContext helperContext) + { + // `return flow;` — no-op tail. + if (expr is IdentifierNameSyntax ident && ident.Identifier.ValueText == flowParamName) + { + return true; + } + + var calls = new List(); + ExpressionSyntax current = expr; + while (current is InvocationExpressionSyntax inv) + { + calls.Add(inv); + if (inv.Expression is MemberAccessExpressionSyntax member) + { + current = member.Expression; + } + else + { + break; + } + } + + // Chain must bottom out at the flow parameter. + if (current is not IdentifierNameSyntax rootIdent || rootIdent.Identifier.ValueText != flowParamName) + { + diagnostics.Add(UnsupportedDiagnostic(expr, + $"fluent helper body must be rooted at the flow parameter '{flowParamName}'")); + return false; + } + + calls.Reverse(); + foreach (var inv in calls) + { + if (inv.Expression is not MemberAccessExpressionSyntax member) continue; + var methodName = member.Name.Identifier.ValueText; + var args = inv.ArgumentList.Arguments; + + var beforeCount = diagnostics.Count; + if (TryInlineFluentHelper(inv, model, sequence, diagnostics, helperContext)) + { + continue; + } + if (diagnostics.Count > beforeCount) + { + // Nested fluent-helper failed with a specific diagnostic — bubble up. + return false; + } + + var step = ParseStep(inv, methodName, args, model, diagnostics, helperContext); + if (step is not null) + { + sequence.Steps.Add(step); + } + } + return true; + } + + /// + /// Walks one statement from a fluent helper's block body. Supports expression statements + /// (flow.X(...);), if statements with compile-time-evaluable conditions, and + /// a terminating return flow; / return flow.X();. + /// + private static bool WalkFluentHelperStatement( + StatementSyntax stmt, + string flowParamName, + SemanticModel model, + StepSequence sequence, + ImmutableArray.Builder diagnostics, + HelperContext helperContext) + { + if (stmt is ExpressionStatementSyntax exprStmt) + { + return WalkFluentHelperExpression(exprStmt.Expression, flowParamName, model, sequence, diagnostics, helperContext); + } + + if (stmt is ReturnStatementSyntax returnStmt) + { + if (returnStmt.Expression is null) return true; + return WalkFluentHelperExpression(returnStmt.Expression, flowParamName, model, sequence, diagnostics, helperContext); + } + + if (stmt is IfStatementSyntax ifStmt) + { + // Compile-time-evaluate the condition. Parameters with call-site literal values + // (e.g. `updatePolicyId: true`) resolve; conditions involving runtime state do not. + var condValue = TryEvaluateBoolCondition(ifStmt.Condition, model, helperContext); + if (condValue is null) + { + diagnostics.Add(UnsupportedDiagnostic(ifStmt.Condition, + "fluent helper 'if' condition must resolve to a compile-time boolean (literal, param default, or call-site-literal argument)")); + return false; + } + + var branch = condValue.Value ? ifStmt.Statement : ifStmt.Else?.Statement; + if (branch is null) return true; // branch was not taken, no statements to emit + + if (branch is BlockSyntax branchBlock) + { + foreach (var s in branchBlock.Statements) + { + if (!WalkFluentHelperStatement(s, flowParamName, model, sequence, diagnostics, helperContext)) + { + return false; + } + } + return true; + } + return WalkFluentHelperStatement(branch, flowParamName, model, sequence, diagnostics, helperContext); + } + + // Local variable declarations, loops, try/throw, etc. aren't supported — they imply + // runtime semantics the generator can't model. + diagnostics.Add(UnsupportedDiagnostic(stmt, + $"fluent helper body statement '{stmt.Kind()}' is not supported by the generator")); + return false; + } + + /// + /// Evaluates a boolean expression at compile time. Supports true/false literals, parameter + /// references that resolve (via the helper-context chain) to such literals or const fields, + /// and any expression for which returns a bool. + /// Returns null when the expression is runtime-bound. + /// + private static bool? TryEvaluateBoolCondition( + ExpressionSyntax expr, + SemanticModel model, + HelperContext helperContext) + { + if (expr is LiteralExpressionSyntax lit) + { + if (lit.IsKind(SyntaxKind.TrueLiteralExpression)) return true; + if (lit.IsKind(SyntaxKind.FalseLiteralExpression)) return false; + } + + if (expr is IdentifierNameSyntax ident && + helperContext.ResolveParameter(ident.Identifier.ValueText) is { } resolution) + { + return TryEvaluateBoolCondition(resolution.Expression, resolution.Model, helperContext); + } + + // Negation: !something + if (expr is PrefixUnaryExpressionSyntax prefix && prefix.IsKind(SyntaxKind.LogicalNotExpression)) + { + var inner = TryEvaluateBoolCondition(prefix.Operand, model, helperContext); + return inner is null ? null : !inner.Value; + } + + var cv = model.GetConstantValue(expr); + if (cv.HasValue && cv.Value is bool b) return b; + + return null; + } + + /// + /// Converts a Roslyn expression for a compile-time-known literal/constant value into a + /// . Handles string / bool / numeric / enum-member shapes. + /// Returns null for anything non-constant. + /// + private static CanonicalExpr? TryParseConstantExpression(ExpressionSyntax expr, SemanticModel semanticModel) + { + if (expr is LiteralExpressionSyntax lit) + { + if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return new StringExpr(lit.Token.ValueText); + if (lit.IsKind(SyntaxKind.TrueLiteralExpression)) return new BoolExpr(true); + if (lit.IsKind(SyntaxKind.FalseLiteralExpression)) return new BoolExpr(false); + if (lit.IsKind(SyntaxKind.NumericLiteralExpression)) return new NumberExpr(lit.Token.ValueText); + if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return new NullExpr(); + } + + // Const fields and enum members resolve via the semantic model. + var constant = semanticModel.GetConstantValue(expr); + if (constant.HasValue) + { + return constant.Value switch + { + null => new NullExpr(), + string s => new StringExpr(s), + bool b => new BoolExpr(b), + int or long or short or byte or sbyte or uint or ulong or ushort or double or float or decimal + => new NumberExpr(System.Convert.ToString(constant.Value, System.Globalization.CultureInfo.InvariantCulture) ?? "0"), + _ => null, + }; + } + + return null; + } + + /// + /// Parses a WorkflowBusinessReferenceDeclaration from an expression. + /// Supports object-initializer syntax and helper method invocations. + /// + private static AssignBusinessReferenceStep? ParseBusinessReferenceDeclaration( + ExpressionSyntax expr, + SemanticModel semanticModel, + ImmutableArray.Builder diagnostics, + HelperContext? helperContext = null) + { + // Try helper method invocation first (e.g., ClaimWorkflowSupport.BuildCurrentClaimBusinessReferenceDeclaration()) + if (expr is InvocationExpressionSyntax helperInv) + { + var symbolInfo = semanticModel.GetSymbolInfo(helperInv); + if (symbolInfo.Symbol is IMethodSymbol helperMethod && + helperMethod.DeclaringSyntaxReferences.Length > 0) + { + var syntaxRef = helperMethod.DeclaringSyntaxReferences[0]; + var methodSyntax = syntaxRef.GetSyntax(); + BlockSyntax? body = null; + ExpressionSyntax? expressionBody = null; + if (methodSyntax is MethodDeclarationSyntax md) + { + body = md.Body; + expressionBody = md.ExpressionBody?.Expression; + } + else if (methodSyntax is LocalFunctionStatementSyntax lf) + { + body = lf.Body; + expressionBody = lf.ExpressionBody?.Expression; + } + + ExpressionSyntax? returnExpr = expressionBody; + if (returnExpr is null && body is not null) + { + ReturnStatementSyntax? returnStmt = null; + foreach (var stmt in body.Statements) + { + if (stmt is ReturnStatementSyntax r && r.Expression is not null) + { + if (returnStmt is not null) break; // multiple returns — bail + returnStmt = r; + } + } + returnExpr = returnStmt?.Expression; + } + + if (returnExpr is not null) + { + var methodTree = syntaxRef.SyntaxTree; + var methodModel = methodTree == semanticModel.SyntaxTree + ? semanticModel + : semanticModel.Compilation.GetSemanticModel(methodTree); + + // Build a HelperContext mapping the helper's parameters to call-site args so + // nested ParseCanonicalExpression calls resolve through the same machinery as + // other inliners. Chain to outer context for multi-hop composition. + var paramMap = new Dictionary(); + var helperArgs = helperInv.ArgumentList.Arguments; + for (var i = 0; i < helperMethod.Parameters.Length && i < helperArgs.Count; i++) + { + paramMap[helperMethod.Parameters[i].Name] = helperArgs[i].Expression; + } + var innerHelperCtx = new HelperContext(paramMap, semanticModel, parent: helperContext); + + return ParseBusinessReferenceDeclaration(returnExpr, methodModel, diagnostics, innerHelperCtx); + } + } + } + + // Object-initializer syntax: new WorkflowBusinessReferenceDeclaration { KeyExpression = ..., Parts = [...] } + if (expr is not ObjectCreationExpressionSyntax objCreate && expr is not ImplicitObjectCreationExpressionSyntax) + { + diagnostics.Add(UnsupportedDiagnostic(expr, + "SetBusinessReference argument must be a WorkflowBusinessReferenceDeclaration object initializer or a helper method")); + return null; + } + + InitializerExpressionSyntax? initializer = null; + if (expr is ObjectCreationExpressionSyntax oc) initializer = oc.Initializer; + else if (expr is ImplicitObjectCreationExpressionSyntax ioc) initializer = ioc.Initializer; + + if (initializer is null) + { + diagnostics.Add(UnsupportedDiagnostic(expr, "SetBusinessReference requires an object initializer")); + return null; + } + + CanonicalExpr? keyExpression = null; + var parts = new List(); + + foreach (var assignment in initializer.Expressions) + { + if (assignment is not AssignmentExpressionSyntax assign || + assign.Left is not IdentifierNameSyntax propName) + { + continue; + } + + var name = propName.Identifier.ValueText; + if (name == "KeyExpression") + { + keyExpression = ParseCanonicalExpression(assign.Right, semanticModel, diagnostics, helperContext); + } + else if (name == "Parts") + { + // Parts can be a collection expression [...] or an array creation + if (assign.Right is CollectionExpressionSyntax partsCollection) + { + foreach (var element in partsCollection.Elements) + { + if (element is ExpressionElementSyntax exprElem) + { + var named = ParseNamedExpr(exprElem.Expression, semanticModel, diagnostics, helperContext); + if (named is null) return null; + parts.Add(named); + } + } + } + else if (assign.Right is ArrayCreationExpressionSyntax arrayCreate && arrayCreate.Initializer is not null) + { + foreach (var element in arrayCreate.Initializer.Expressions) + { + var named = ParseNamedExpr(element, semanticModel, diagnostics, helperContext); + if (named is null) return null; + parts.Add(named); + } + } + else if (assign.Right is ImplicitArrayCreationExpressionSyntax implArray && implArray.Initializer is not null) + { + foreach (var element in implArray.Initializer.Expressions) + { + var named = ParseNamedExpr(element, semanticModel, diagnostics, helperContext); + if (named is null) return null; + parts.Add(named); + } + } + } + } + + return new AssignBusinessReferenceStep(keyExpression, parts); + } + + private readonly struct InvocationDecl + { + public InvocationDecl( + CanonicalExpr? workflowNameExpression, + CanonicalExpr? workflowVersionExpression, + CanonicalExpr? payloadExpression, + CanonicalExpr? businessReferenceKeyExpression, + IReadOnlyList? businessReferenceParts) + { + WorkflowNameExpression = workflowNameExpression; + WorkflowVersionExpression = workflowVersionExpression; + PayloadExpression = payloadExpression; + BusinessReferenceKeyExpression = businessReferenceKeyExpression; + BusinessReferenceParts = businessReferenceParts ?? new List(); + } + + public CanonicalExpr? WorkflowNameExpression { get; } + public CanonicalExpr? WorkflowVersionExpression { get; } + public CanonicalExpr? PayloadExpression { get; } + public CanonicalExpr? BusinessReferenceKeyExpression { get; } + public IReadOnlyList BusinessReferenceParts { get; } + + public ContinueWithStep ToContinueWithStep(string stepName) => new( + stepName, + WorkflowNameExpression, + WorkflowVersionExpression, + PayloadExpression, + BusinessReferenceKeyExpression, + BusinessReferenceParts); + } + + /// + /// Parses a WorkflowWorkflowInvocationDeclaration from an expression. + /// Supports helper method invocations (including nested ones that receive their workflow + /// reference as a parameter) and object-initializer syntax. + /// + /// + /// Two context-propagation mechanisms coexist here: + /// + /// Legacy / used by the + /// TryBuildCanonicalInvocationDeclaration receiver-resolution path. + /// Modern that threads through the outer fluent-chain + /// walker. This makes ParseCanonicalExpression / ParseBusinessReferenceDeclaration + /// calls inside the invocation-declaration body see parameters from enclosing helpers — + /// so .ContinueWith("step", SomeHelper.Build(innerParam)) can resolve innerParam + /// via the caller's HelperContext.Parent chain. + /// + /// + private static InvocationDecl? ParseInvocationDeclaration( + ExpressionSyntax expr, + SemanticModel semanticModel, + ImmutableArray.Builder diagnostics, + Dictionary? paramMap = null, + SemanticModel? callSiteModel = null, + HelperContext? helperContext = null) + { + // Follow helper methods that return WorkflowWorkflowInvocationDeclaration + if (expr is InvocationExpressionSyntax helperInv) + { + var symbolInfo = semanticModel.GetSymbolInfo(helperInv); + if (symbolInfo.Symbol is IMethodSymbol helperMethod && + helperMethod.DeclaringSyntaxReferences.Length > 0) + { + var syntaxRef = helperMethod.DeclaringSyntaxReferences[0]; + var methodSyntax = syntaxRef.GetSyntax(); + BlockSyntax? body = null; + ExpressionSyntax? expressionBody = null; + if (methodSyntax is MethodDeclarationSyntax md) + { + body = md.Body; + expressionBody = md.ExpressionBody?.Expression; + } + else if (methodSyntax is LocalFunctionStatementSyntax lf) + { + body = lf.Body; + expressionBody = lf.ExpressionBody?.Expression; + } + + ExpressionSyntax? returnExpr = expressionBody; + if (returnExpr is null && body is not null) + { + ReturnStatementSyntax? returnStmt = null; + foreach (var stmt in body.Statements) + { + if (stmt is ReturnStatementSyntax r && r.Expression is not null) + { + if (returnStmt is not null) { returnStmt = null; break; } + returnStmt = r; + } + } + returnExpr = returnStmt?.Expression; + } + + if (returnExpr is not null) + { + var methodTree = syntaxRef.SyntaxTree; + var methodModel = methodTree == semanticModel.SyntaxTree + ? semanticModel + : semanticModel.Compilation.GetSemanticModel(methodTree); + + // Build param map for this helper's parameters → call-site arguments + var innerParamMap = new Dictionary(); + var helperArgs = helperInv.ArgumentList.Arguments; + for (var i = 0; i < helperMethod.Parameters.Length && i < helperArgs.Count; i++) + { + innerParamMap[helperMethod.Parameters[i].Name] = helperArgs[i].Expression; + } + + if (returnExpr is BinaryExpressionSyntax coalesce && + coalesce.IsKind(SyntaxKind.CoalesceExpression)) + { + returnExpr = coalesce.Left; + } + + // Chain HelperContext so payload / name / BusinessReference expressions inside + // the helper body can reach the outer scope's parameters. + var innerHelperContext = new HelperContext(innerParamMap, semanticModel, parent: helperContext); + + return ParseInvocationDeclaration( + returnExpr, methodModel, diagnostics, + innerParamMap, semanticModel, innerHelperContext); + } + } + } + + // Handle method call pattern: workflowRef.TryBuildCanonicalInvocationDeclaration(payloadExpr, businessRefDecl) + if (expr is InvocationExpressionSyntax methodCall && + methodCall.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.ValueText == "TryBuildCanonicalInvocationDeclaration") + { + // Resolve the WorkflowReference to get the workflow name. + // Prefer the HelperContext chain (resolves through multi-hop parameter forwarding); + // fall back to the legacy paramMap/callSiteModel for call sites that don't yet + // carry a HelperContext. + string? workflowName = null; + var receiverExpr = memberAccess.Expression; + if (receiverExpr is IdentifierNameSyntax paramIdent) + { + if (helperContext is not null && + helperContext.ResolveParameter(paramIdent.Identifier.ValueText) is { } helperRes) + { + workflowName = ResolveWorkflowReferenceFieldName(helperRes.Expression, helperRes.Model); + } + else if (paramMap is not null && callSiteModel is not null && + paramMap.TryGetValue(paramIdent.Identifier.ValueText, out var mappedReceiver)) + { + workflowName = ResolveWorkflowReferenceFieldName(mappedReceiver, callSiteModel); + } + } + workflowName ??= ResolveWorkflowReferenceFieldName(receiverExpr, semanticModel); + + var mcArgs = methodCall.ArgumentList.Arguments; + CanonicalExpr? payloadExpr = null; + CanonicalExpr? brKeyExpr = null; + var brParts = new List(); + + if (mcArgs.Count >= 1) + { + payloadExpr = ParseCanonicalExpression(mcArgs[0].Expression, semanticModel, diagnostics, helperContext); + } + if (mcArgs.Count >= 2) + { + var brDecl = ParseBusinessReferenceDeclaration(mcArgs[1].Expression, semanticModel, diagnostics, helperContext); + if (brDecl is not null) + { + brKeyExpr = brDecl.KeyExpression; + brParts = new List(brDecl.Parts); + } + } + + return new InvocationDecl( + workflowName is not null ? new StringExpr(workflowName) : null, + null, + payloadExpr, + brKeyExpr, + brParts); + } + + // Object-initializer: new WorkflowWorkflowInvocationDeclaration { ... } + InitializerExpressionSyntax? initializer = null; + if (expr is ObjectCreationExpressionSyntax oc) initializer = oc.Initializer; + else if (expr is ImplicitObjectCreationExpressionSyntax ioc) initializer = ioc.Initializer; + + if (initializer is not null) + { + CanonicalExpr? nameExpr = null, versionExpr = null, payloadExpr = null; + CanonicalExpr? brKeyExpr = null; + var brParts = new List(); + + foreach (var assignment in initializer.Expressions) + { + if (assignment is not AssignmentExpressionSyntax assign || + assign.Left is not IdentifierNameSyntax propName) continue; + + switch (propName.Identifier.ValueText) + { + case "WorkflowNameExpression": + nameExpr = ParseCanonicalExpression(assign.Right, semanticModel, diagnostics, helperContext); + break; + case "WorkflowVersionExpression": + versionExpr = ParseCanonicalExpression(assign.Right, semanticModel, diagnostics, helperContext); + break; + case "PayloadExpression": + payloadExpr = ParseCanonicalExpression(assign.Right, semanticModel, diagnostics, helperContext); + break; + case "BusinessReference": + var br = ParseBusinessReferenceDeclaration(assign.Right, semanticModel, diagnostics, helperContext); + if (br is not null) { brKeyExpr = br.KeyExpression; brParts = new List(br.Parts); } + break; + } + } + + return new InvocationDecl( + nameExpr, + versionExpr, + payloadExpr, + brKeyExpr, + brParts); + } + + diagnostics.Add(UnsupportedDiagnostic(expr, "ContinueWith invocation declaration could not be resolved")); + return null; + } + + /// + /// Resolves a WorkflowReference expression (field or constructor) to the static workflow name string. + /// + private static string? ResolveWorkflowReferenceFieldName(ExpressionSyntax expr, SemanticModel model) + { + var symbol = model.GetSymbolInfo(expr).Symbol; + if (symbol is IFieldSymbol field) + { + foreach (var fieldRef in field.DeclaringSyntaxReferences) + { + if (fieldRef.GetSyntax() is VariableDeclaratorSyntax varDecl && varDecl.Initializer?.Value is not null) + { + var fieldModel = fieldRef.SyntaxTree == model.SyntaxTree + ? model + : model.Compilation.GetSemanticModel(fieldRef.SyntaxTree); + if (varDecl.Initializer.Value is ObjectCreationExpressionSyntax oc && + oc.ArgumentList?.Arguments.Count > 0) + { + return TryResolveStringLiteral(oc.ArgumentList.Arguments[0].Expression, fieldModel); + } + if (varDecl.Initializer.Value is ImplicitObjectCreationExpressionSyntax ioc && + ioc.ArgumentList?.Arguments.Count > 0) + { + return TryResolveStringLiteral(ioc.ArgumentList.Arguments[0].Expression, fieldModel); + } + } + } + } + return null; + } + + /// + /// Resolves a string literal or const string field reference to its compile-time value. + /// Handles "literal", ConstFieldName / Type.ConstField, string.Empty, + /// and nameof(...). + /// + private static string? TryResolveStringLiteral(ExpressionSyntax expr, SemanticModel semanticModel) + { + if (expr is LiteralExpressionSyntax lit && lit.IsKind(SyntaxKind.StringLiteralExpression)) + { + return lit.Token.ValueText; + } + + var symbolInfo = semanticModel.GetSymbolInfo(expr); + if (symbolInfo.Symbol is IFieldSymbol field && field.HasConstantValue && field.ConstantValue is string constStr) + { + return constStr; + } + + // string.Empty — static readonly, not a const, so the previous check misses it. + if (expr is MemberAccessExpressionSyntax ma && + ma.Expression is PredefinedTypeSyntax pts && + pts.Keyword.IsKind(SyntaxKind.StringKeyword) && + ma.Name.Identifier.ValueText == "Empty") + { + return string.Empty; + } + + // Fallback to Roslyn's constant evaluation (covers interpolated-const strings + // and some nameof / static-readonly shapes the checks above don't reach). + var constantValue = semanticModel.GetConstantValue(expr); + if (constantValue.HasValue && constantValue.Value is string s) + { + return s; + } + + return null; + } + + /// + /// Resolves a method-group reference or identifier to the method's body syntax. + /// Used for .StartWith(BuildStartFlow) and .AddTask(CreatePaymentTask()). + /// + private static BlockSyntax? TryResolveMethodBody(ExpressionSyntax expr, SemanticModel semanticModel) + { + IMethodSymbol? method = null; + if (expr is IdentifierNameSyntax || expr is MemberAccessExpressionSyntax) + { + var symbol = semanticModel.GetSymbolInfo(expr).Symbol; + if (symbol is IMethodSymbol ms) + { + method = ms; + } + } + else if (expr is InvocationExpressionSyntax invocation) + { + var symbol = semanticModel.GetSymbolInfo(invocation).Symbol; + if (symbol is IMethodSymbol ms) + { + method = ms; + } + } + + if (method is null) + { + return null; + } + + foreach (var syntaxRef in method.DeclaringSyntaxReferences) + { + var syntax = syntaxRef.GetSyntax(); + if (syntax is MethodDeclarationSyntax methodDecl && methodDecl.Body is not null) + { + return methodDecl.Body; + } + if (syntax is LocalFunctionStatementSyntax localFunc && localFunc.Body is not null) + { + return localFunc.Body; + } + } + + return null; + } + + /// + /// Tries to inline a helper-method invocation that returns a workflow expression + /// type (e.g. ClaimWorkflowSupport.BuildPayload(...)). Resolves the method + /// body to its single return expression and recursively parses it through + /// , substituting method parameters with the + /// call-site argument expressions. + /// + private static CanonicalExpr? TryInlineHelperExpression( + InvocationExpressionSyntax invocation, + SemanticModel callSiteModel, + ImmutableArray.Builder diagnostics, + HelperContext? outerContext) + { + var symbolInfo = callSiteModel.GetSymbolInfo(invocation); + if (symbolInfo.Symbol is not IMethodSymbol method) + { + return null; + } + + // Return type must be from a trusted workflow assembly (WorkflowExpressionDefinition etc.) + if (method.ReturnType is not INamedTypeSymbol returnType || + !WorkflowWellKnownTypes.IsTrustedAssembly(returnType.ContainingAssembly)) + { + return null; + } + + // Fragment-loader conventions are handled by the specialized resolvers + // (TryResolveLazyFragmentValue and TryResolveDirectFragmentCall). Walking their bodies + // would take us into the non-canonical infrastructure classes (WorkflowCanonicalTemplateLoader + // etc.) and emit misleading WF020 diagnostics. The direct-call resolver fires at the call + // site before this function does, so reaching here means the call site chose not to use it + // (e.g., binding-template helpers where runtime substitution is required); in that case + // we gracefully decline to inline rather than walking into runtime-only code. + if (method.Name == "LoadFragment" || method.Name == "LoadEmbeddedFragment") + { + return null; + } + + // Method must be in source (current compilation) + if (method.DeclaringSyntaxReferences.Length == 0) + { + return null; + } + + var syntaxRef = method.DeclaringSyntaxReferences[0]; + var methodSyntax = syntaxRef.GetSyntax(); + BlockSyntax? body = null; + ExpressionSyntax? expressionBody = null; + if (methodSyntax is MethodDeclarationSyntax md) + { + body = md.Body; + expressionBody = md.ExpressionBody?.Expression; + } + else if (methodSyntax is LocalFunctionStatementSyntax lf) + { + body = lf.Body; + expressionBody = lf.ExpressionBody?.Expression; + } + + // Resolve the method's "return expression" — expression-body methods declare it + // directly; block bodies are accepted only if they contain exactly one return (guard + // clauses such as `ArgumentNullException.ThrowIfNull(...)` as separate statements + // are fine and ignored). + ExpressionSyntax? returnExpr = expressionBody; + if (returnExpr is null) + { + if (body is null) return null; + ReturnStatementSyntax? returnStmt = null; + foreach (var stmt in body.Statements) + { + if (stmt is ReturnStatementSyntax r && r.Expression is not null) + { + if (returnStmt is not null) return null; // multiple returns — too complex, bail + returnStmt = r; + } + } + returnExpr = returnStmt?.Expression; + } + if (returnExpr is null) return null; + + // Build parameter-to-argument map, including default values for omitted args + var paramMap = new Dictionary(); + var args = invocation.ArgumentList.Arguments; + string? paramsParamName = null; + List? paramsArgs = null; + + for (var i = 0; i < method.Parameters.Length; i++) + { + var param = method.Parameters[i]; + if (param.IsParams) + { + // Collect remaining call-site arguments for the params array + paramsParamName = param.Name; + paramsArgs = new List(); + for (var j = i; j < args.Count; j++) + { + paramsArgs.Add(args[j].Expression); + } + break; + } + + if (i < args.Count) + { + paramMap[param.Name] = args[i].Expression; + } + else if (param.DeclaringSyntaxReferences.Length > 0) + { + // Use the default parameter value from the syntax + var paramSyntax = param.DeclaringSyntaxReferences[0].GetSyntax() as ParameterSyntax; + if (paramSyntax?.Default?.Value is not null) + { + paramMap[param.Name] = paramSyntax.Default.Value; + } + } + } + + // Walk the method body for ??= assignments that refine parameter values. + // Pattern: param ??= WorkflowExpr.Something(...) — if the mapped value is + // null literal, replace with the RHS. Expression-bodied methods skip this. + if (body is not null) + { + foreach (var stmt in body.Statements) + { + if (stmt is ExpressionStatementSyntax exprStmt && + exprStmt.Expression is AssignmentExpressionSyntax assignment && + assignment.IsKind(SyntaxKind.CoalesceAssignmentExpression) && + assignment.Left is IdentifierNameSyntax leftIdent && + paramMap.TryGetValue(leftIdent.Identifier.ValueText, out var currentValue) && + currentValue is LiteralExpressionSyntax nullLit && + nullLit.IsKind(SyntaxKind.NullLiteralExpression)) + { + // Current value is null → ??= applies; use the RHS + paramMap[leftIdent.Identifier.ValueText] = assignment.Right; + } + } + } + + // Get the correct semantic model for the helper method's syntax tree + var methodTree = syntaxRef.SyntaxTree; + SemanticModel methodModel; + if (methodTree == callSiteModel.SyntaxTree) + { + methodModel = callSiteModel; + } + else + { + methodModel = callSiteModel.Compilation.GetSemanticModel(methodTree); + } + + // Chain to the outer helper context so that when the helper's body substitutes a + // param with an expression that still references identifiers from the caller's + // scope, those references can still resolve (two-level helper composition). + var helperCtx = new HelperContext(paramMap, callSiteModel, paramsParamName, paramsArgs, parent: outerContext); + return ParseCanonicalExpression(returnExpr, methodModel, diagnostics, helperCtx); + } + + /// + /// Resolves an interpolated string expression to a compile-time string value. + /// Each interpolation part must be resolvable: literal text or a parameter + /// reference from the that maps to a string literal. + /// Returns null if any part cannot be resolved. + /// + private static string? TryResolveInterpolatedString( + InterpolatedStringExpressionSyntax interpolated, + HelperContext? helperContext) + { + var sb = new StringBuilder(); + foreach (var part in interpolated.Contents) + { + if (part is InterpolatedStringTextSyntax text) + { + sb.Append(text.TextToken.ValueText); + } + else if (part is InterpolationSyntax interpolation) + { + // The embedded expression must be a parameter reference that maps to a + // string literal. ResolveParameter chases identifier-to-identifier + // forwardings through the parent chain, so outer-scope parameters still + // resolve when a nested helper forwards its own parameter unchanged. + if (helperContext is null) return null; + if (interpolation.Expression is not IdentifierNameSyntax ident) return null; + + if (helperContext.ResolveParameter(ident.Identifier.ValueText) is not { } resolution) + return null; + + var resolved = TryResolveStringLiteral(resolution.Expression, resolution.Model); + if (resolved is null) return null; + sb.Append(resolved); + } + else + { + return null; + } + } + return sb.ToString(); + } + + /// + /// Walks a method body (from a method-group reference in StartWith or OnComplete) + /// looking for flow builder method calls on the first parameter. + /// + private static StepSequence? WalkFlowMethodBody( + BlockSyntax body, + SemanticModel semanticModel, + ImmutableArray.Builder diagnostics) + { + // The method body should contain flow builder calls like: + // flow.Call(...).Set(...).ActivateTask(...); + // Each expression statement is a fluent chain we can walk. + var sequence = new StepSequence(); + foreach (var statement in body.Statements) + { + if (statement is ExpressionStatementSyntax exprStmt) + { + WalkFlowChain(exprStmt.Expression, semanticModel, sequence, diagnostics); + } + } + return sequence.Steps.Count > 0 ? sequence : null; + } + private static CanonicalAddress? ParseAddress( ExpressionSyntax expr, SemanticModel semanticModel, @@ -858,6 +2766,23 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator if (syntax is VariableDeclaratorSyntax decl && decl.Initializer?.Value is { } init) { + // If the initializer is in a different SyntaxTree, the caller's + // SemanticModel cannot be used on its nodes (→ ArgumentException + // "Syntax node is not within syntax tree"). Acquire the correct + // SemanticModel for the initializer's tree and recurse — this lets + // us follow chains like `= HomeWorkflowSupport.PersonsLookupAddress` + // through to their real `new Address(...)` definition. + if (!ReferenceEquals(init.SyntaxTree, semanticModel.SyntaxTree)) + { + // Direct `new X(...)` ctor: the syntactic fast path needs no + // semantic model, so keep it for minor overhead savings. + if (init is ObjectCreationExpressionSyntax crossOc) + { + return ParseAddressFromCtor(crossOc.Type, crossOc.ArgumentList?.Arguments, crossOc, diagnostics); + } + var crossModel = semanticModel.Compilation.GetSemanticModel(init.SyntaxTree); + return ParseAddress(init, crossModel, diagnostics); + } return ParseAddress(init, semanticModel, diagnostics); } } @@ -966,15 +2891,22 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator { case "For": // WorkflowHumanTask.For(taskName, taskType, route, taskRoles?) - if (args.Count < 3 || - args[0].Expression is not LiteralExpressionSyntax nameL || !nameL.IsKind(SyntaxKind.StringLiteralExpression) || - args[1].Expression is not LiteralExpressionSyntax typeL || !typeL.IsKind(SyntaxKind.StringLiteralExpression) || - args[2].Expression is not LiteralExpressionSyntax routeL || !routeL.IsKind(SyntaxKind.StringLiteralExpression)) { - diagnostics.Add(UnsupportedDiagnostic(inv, "WorkflowHumanTask.For requires literal (name, type, route) arguments")); - return null; + if (args.Count < 3) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "WorkflowHumanTask.For requires at least 3 arguments (name, type, route)")); + return null; + } + var taskName = TryResolveStringLiteral(args[0].Expression, semanticModel); + var taskType = TryResolveStringLiteral(args[1].Expression, semanticModel); + var taskRoute = TryResolveStringLiteral(args[2].Expression, semanticModel); + if (taskName is null || taskType is null || taskRoute is null) + { + diagnostics.Add(UnsupportedDiagnostic(inv, "WorkflowHumanTask.For requires literal or const field (name, type, route) arguments")); + return null; + } + task = new CanonicalTask(taskName, taskType, taskRoute); } - task = new CanonicalTask(nameL.Token.ValueText, typeL.Token.ValueText, routeL.Token.ValueText); break; case "WithRoles": { @@ -1029,6 +2961,18 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator { task.OnComplete = WalkFlowLambda(onCompleteLambda, semanticModel, diagnostics); } + else if (args.Count >= 1) + { + // Method-group reference: resolve to method body and walk its flow statements + var onCompleteBody = TryResolveMethodBody(args[0].Expression, semanticModel); + if (onCompleteBody is not null) + { + var bodyModel = onCompleteBody.SyntaxTree == semanticModel.SyntaxTree + ? semanticModel + : semanticModel.Compilation.GetSemanticModel(onCompleteBody.SyntaxTree); + task.OnComplete = WalkFlowMethodBody(onCompleteBody, bodyModel, diagnostics); + } + } break; } default: @@ -1042,9 +2986,93 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator private static CanonicalExpr? ParseCanonicalExpression( ExpressionSyntax expr, SemanticModel semanticModel, - ImmutableArray.Builder diagnostics) + ImmutableArray.Builder diagnostics, + HelperContext? helperContext = null) { - // Expected: WorkflowExpr.() + // 1. If we are inside a helper-method body, check whether this expression + // is an identifier that references a method parameter. If so, substitute + // with the corresponding call-site argument expression. Walk the parent + // chain so identifiers introduced by an outer helper still resolve. + if (helperContext is not null && expr is IdentifierNameSyntax paramRef && + helperContext.ResolveParameter(paramRef.Identifier.ValueText) is { } paramResolution) + { + return ParseCanonicalExpression(paramResolution.Expression, paramResolution.Model, diagnostics, helperContext.Parent); + } + + // 2. Bare literals: the fluent API has implicit conversions from bool/string/int + // to WorkflowExpressionDefinition, e.g. .Set("key", true) or .Set("key", "Task"). + if (expr is LiteralExpressionSyntax literal) + { + if (literal.IsKind(SyntaxKind.TrueLiteralExpression)) return new BoolExpr(true); + if (literal.IsKind(SyntaxKind.FalseLiteralExpression)) return new BoolExpr(false); + if (literal.IsKind(SyntaxKind.StringLiteralExpression)) return new StringExpr(literal.Token.ValueText); + if (literal.IsKind(SyntaxKind.NumericLiteralExpression)) return new NumberExpr(literal.Token.Text); + if (literal.IsKind(SyntaxKind.NullLiteralExpression)) return new NullExpr(); + } + + // 2a. Null-coalesce expression `left ?? right` — common helper pattern where an + // optional `WorkflowExpressionDefinition? arg` defaults to a fallback expression. + // Evaluate left first; if it resolves to a null/NullExpr, use right instead. + if (expr is BinaryExpressionSyntax coalesceBinary && coalesceBinary.IsKind(SyntaxKind.CoalesceExpression)) + { + // If the LEFT is itself a plain null literal or a parameter whose call-site arg is + // null, skip directly to the right. Otherwise parse left and only fall back to right + // on a NullExpr result (never produce a WF020 for left alone — the right is the + // intended fallback). + var leftIsNull = + (coalesceBinary.Left is LiteralExpressionSyntax leftLit && leftLit.IsKind(SyntaxKind.NullLiteralExpression)) + || (helperContext is not null && + coalesceBinary.Left is IdentifierNameSyntax leftIdent && + helperContext.ResolveParameter(leftIdent.Identifier.ValueText) is { } leftRes && + leftRes.Expression is LiteralExpressionSyntax leftMappedLit && + leftMappedLit.IsKind(SyntaxKind.NullLiteralExpression)); + if (leftIsNull) + { + return ParseCanonicalExpression(coalesceBinary.Right, semanticModel, diagnostics, helperContext); + } + // Left is non-null. Parse it suppressing diagnostics; if it comes back as NullExpr, + // still use the right side. If it errors, fall through to the normal parse path. + var leftScratch = ImmutableArray.CreateBuilder(); + var leftResult = ParseCanonicalExpression(coalesceBinary.Left, semanticModel, leftScratch, helperContext); + if (leftResult is not null and not NullExpr) + { + foreach (var d in leftScratch) diagnostics.Add(d); + return leftResult; + } + return ParseCanonicalExpression(coalesceBinary.Right, semanticModel, diagnostics, helperContext); + } + + // 3. Direct invocation without member access (e.g., BuildPayload(...) called + // from within the same class). Try to inline as a helper before giving up. + if (expr is InvocationExpressionSyntax directInv && + directInv.Expression is not MemberAccessExpressionSyntax) + { + var beforeCount = diagnostics.Count; + var inlined = TryInlineHelperExpression(directInv, semanticModel, diagnostics, helperContext); + if (inlined is not null) return inlined; + // Inliner added its own diagnostic — don't double-report with a generic one. + if (diagnostics.Count > beforeCount) return null; + } + + // 3a. Canonical JSON fragment expansion: `SomeLazyField.Value` where SomeLazyField is + // declared as `Lazy(() => LoadFragment("X.json"))`. + // The runtime loads the embedded JSON at first access; the generator inlines the + // parsed fragment directly so the canonical output is fully materialised. + if (expr is MemberAccessExpressionSyntax valueAccess && + TryResolveLazyFragmentValue(valueAccess, semanticModel, diagnostics) is { } fragmentExpr) + { + return fragmentExpr; + } + + // 3b. Direct canonical-fragment loader call: `Loader.LoadFragment("X.json")` without + // the Lazy wrapper. Same compile-time inline as 3a. + if (expr is InvocationExpressionSyntax loadInv && + TryResolveDirectFragmentCall(loadInv, semanticModel, diagnostics) is { } directFragmentExpr) + { + return directFragmentExpr; + } + + // 3. Expected: WorkflowExpr.() if (expr is not InvocationExpressionSyntax inv || inv.Expression is not MemberAccessExpressionSyntax member) { @@ -1056,6 +3084,15 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator var targetType = (member.Expression as IdentifierNameSyntax)?.Identifier.ValueText; if (targetType != "WorkflowExpr") { + // 3. Before giving up, try to inline a helper method that returns a + // workflow expression type (WorkflowExpressionDefinition etc.). + var beforeCount = diagnostics.Count; + var inlined = TryInlineHelperExpression(inv, semanticModel, diagnostics, helperContext); + if (inlined is not null) return inlined; + // If the inliner already pinpointed the issue, don't stack a less-specific + // "saw '{type}'" diagnostic on top of it. + if (diagnostics.Count > beforeCount) return null; + diagnostics.Add(UnsupportedDiagnostic(expr, $"canonical expressions must be on WorkflowExpr, saw '{targetType ?? ""}'")); return null; @@ -1069,18 +3106,71 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator case "Null": return new NullExpr(); case "String": - if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax sLit && sLit.IsKind(SyntaxKind.StringLiteralExpression)) { - return new StringExpr(sLit.Token.ValueText); + if (args.Count != 1) break; + if (args[0].Expression is LiteralExpressionSyntax sLit && sLit.IsKind(SyntaxKind.StringLiteralExpression)) + { + return new StringExpr(sLit.Token.ValueText); + } + // Parameter reference inside WorkflowExpr.String(param) — walks the + // parent helper-context chain so outer-scope identifiers resolve. + if (helperContext is not null && args[0].Expression is IdentifierNameSyntax sParam && + helperContext.ResolveParameter(sParam.Identifier.ValueText) is { } sResolved) + { + var str = TryResolveStringLiteral(sResolved.Expression, sResolved.Model); + if (str is not null) return new StringExpr(str); + } + // Interpolated string WorkflowExpr.String($"prefix_{param}") + if (args[0].Expression is InterpolatedStringExpressionSyntax sInterp) + { + var resolved = TryResolveInterpolatedString(sInterp, helperContext); + if (resolved is not null) return new StringExpr(resolved); + } + // Const field reference: WorkflowExpr.String(ClassName.ConstField) + { + var resolved = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (resolved is not null) return new StringExpr(resolved); + } + break; } - break; case "Bool": - if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax bLit) { - if (bLit.IsKind(SyntaxKind.TrueLiteralExpression)) return new BoolExpr(true); - if (bLit.IsKind(SyntaxKind.FalseLiteralExpression)) return new BoolExpr(false); + if (args.Count != 1) break; + if (args[0].Expression is LiteralExpressionSyntax bLit) + { + if (bLit.IsKind(SyntaxKind.TrueLiteralExpression)) return new BoolExpr(true); + if (bLit.IsKind(SyntaxKind.FalseLiteralExpression)) return new BoolExpr(false); + } + // Parameter reference: WorkflowExpr.Bool(param) — walks the parent chain. + if (helperContext is not null && args[0].Expression is IdentifierNameSyntax bParam && + helperContext.ResolveParameter(bParam.Identifier.ValueText) is { } bResolved) + { + if (bResolved.Expression is LiteralExpressionSyntax bMappedLit) + { + if (bMappedLit.IsKind(SyntaxKind.TrueLiteralExpression)) return new BoolExpr(true); + if (bMappedLit.IsKind(SyntaxKind.FalseLiteralExpression)) return new BoolExpr(false); + } + // Const-bool field from the mapped expression (resolved via its model). + var bSymbol = bResolved.Model.GetSymbolInfo(bResolved.Expression).Symbol; + if (bSymbol is IFieldSymbol bField && bField.HasConstantValue && bField.ConstantValue is bool bVal) + { + return new BoolExpr(bVal); + } + // Multi-hop: the mapped expression is itself another identifier from + // the outer scope. Recurse with parent context so the chain unwinds. + var bRec = ParseCanonicalExpression(bResolved.Expression, bResolved.Model, diagnostics, helperContext.Parent); + if (bRec is BoolExpr bRecExpr) return bRecExpr; + } + // Const field reference: WorkflowExpr.Bool(ClassName.ConstField) + { + var symbolInfo = semanticModel.GetSymbolInfo(args[0].Expression); + if (symbolInfo.Symbol is IFieldSymbol bField && bField.HasConstantValue && bField.ConstantValue is bool bVal) + { + return new BoolExpr(bVal); + } + } + break; } - break; case "Number": if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax nLit) { @@ -1089,17 +3179,90 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator } break; case "Path": - if (args.Count == 1 && args[0].Expression is LiteralExpressionSyntax pLit && pLit.IsKind(SyntaxKind.StringLiteralExpression)) { - return new PathExpr(pLit.Token.ValueText); + if (args.Count != 1) break; + if (args[0].Expression is LiteralExpressionSyntax pLit && pLit.IsKind(SyntaxKind.StringLiteralExpression)) + { + return new PathExpr(pLit.Token.ValueText); + } + // Interpolated string WorkflowExpr.Path($"result.{param}") + if (args[0].Expression is InterpolatedStringExpressionSyntax pInterp) + { + var resolved = TryResolveInterpolatedString(pInterp, helperContext); + if (resolved is not null) return new PathExpr(resolved); + } + // Parameter reference inside WorkflowExpr.Path(param) — walks parent chain. + if (helperContext is not null && args[0].Expression is IdentifierNameSyntax pParam && + helperContext.ResolveParameter(pParam.Identifier.ValueText) is { } pResolved) + { + var pstr = TryResolveStringLiteral(pResolved.Expression, pResolved.Model); + if (pstr is not null) return new PathExpr(pstr); + } + // Const field reference: WorkflowExpr.Path(ClassName.ConstField) + { + var resolved = TryResolveStringLiteral(args[0].Expression, semanticModel); + if (resolved is not null) return new PathExpr(resolved); + } + break; } - break; case "Obj": { var props = new List(); + + // Handle collection expression syntax: WorkflowExpr.Obj([Prop1, Prop2, ..spread]) + if (args.Count == 1 && args[0].Expression is CollectionExpressionSyntax collExpr) + { + foreach (var element in collExpr.Elements) + { + if (element is ExpressionElementSyntax exprElem) + { + var named = ParseNamedExpr(exprElem.Expression, semanticModel, diagnostics, helperContext); + if (named is null) return null; + props.Add(named); + } + else if (element is SpreadElementSyntax spread && + helperContext?.ParamsParameterName is not null && + spread.Expression is IdentifierNameSyntax spreadIdent && + spreadIdent.Identifier.ValueText == helperContext.ParamsParameterName && + helperContext.ParamsArgs is not null) + { + // Expand the params array arguments from the call site + foreach (var paramsArg in helperContext.ParamsArgs) + { + var named = ParseNamedExpr(paramsArg, helperContext.CallSiteModel, diagnostics, null); + if (named is null) return null; + props.Add(named); + } + } + else if (element is SpreadElementSyntax condSpread && + TryExpandConditionalSpread(condSpread, semanticModel, helperContext, props, diagnostics)) + { + // Conditional ternary spread e.g. `.. (includeFoo ? [Prop(...)] : [])` + // — evaluated at compile time via helper-context parameter mapping. + } + else + { + diagnostics.Add(UnsupportedDiagnostic(element, + "unsupported collection element in WorkflowExpr.Obj([...])")); + return null; + } + } + return new ObjectExpr(props); + } + + // Handle WorkflowExpr.Obj(param) where param maps to a params-expanded list + if (args.Count == 1 && helperContext is not null && + args[0].Expression is IdentifierNameSyntax objParamRef && + !objParamRef.Identifier.ValueText.StartsWith("WorkflowExpr")) + { + // This could be Obj(properties) where properties is a local variable + // built imperatively. Not inlinable — fall through to normal handling. + } + + // Standard params syntax: WorkflowExpr.Obj(Prop1, Prop2, ...) foreach (var a in args) { - var named = ParseNamedExpr(a.Expression, semanticModel, diagnostics); + var named = ParseNamedExpr(a.Expression, semanticModel, diagnostics, helperContext); if (named is null) return null; props.Add(named); } @@ -1110,7 +3273,7 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator var items = new List(); foreach (var a in args) { - var item = ParseCanonicalExpression(a.Expression, semanticModel, diagnostics); + var item = ParseCanonicalExpression(a.Expression, semanticModel, diagnostics, helperContext); if (item is null) return null; items.Add(item); } @@ -1120,7 +3283,7 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator { if (args.Count == 1) { - var operand = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics); + var operand = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics, helperContext); if (operand is null) return null; return new UnaryExpr("not", operand); } @@ -1136,8 +3299,8 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator case "Or": if (args.Count == 2) { - var left = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics); - var right = ParseCanonicalExpression(args[1].Expression, semanticModel, diagnostics); + var left = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics, helperContext); + var right = ParseCanonicalExpression(args[1].Expression, semanticModel, diagnostics, helperContext); if (left is null || right is null) return null; return new BinaryExpr(methodName.ToLowerInvariant(), left, right); } @@ -1145,25 +3308,55 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator case "Group": if (args.Count == 1) { - var inner = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics); + var inner = ParseCanonicalExpression(args[0].Expression, semanticModel, diagnostics, helperContext); if (inner is null) return null; return new GroupExpr(inner); } break; case "Func": - if (args.Count >= 1 && args[0].Expression is LiteralExpressionSyntax fnLit && fnLit.IsKind(SyntaxKind.StringLiteralExpression)) { - var fnName = fnLit.Token.ValueText; - var fnArgs = new List(); - for (var i = 1; i < args.Count; i++) + if (args.Count < 1) break; + // Function name can be a literal or a parameter reference + string? fnName = null; + if (args[0].Expression is LiteralExpressionSyntax fnLit && fnLit.IsKind(SyntaxKind.StringLiteralExpression)) { - var fa = ParseCanonicalExpression(args[i].Expression, semanticModel, diagnostics); - if (fa is null) return null; - fnArgs.Add(fa); + fnName = fnLit.Token.ValueText; } - return new FunctionExpr(fnName, fnArgs); + else if (helperContext is not null && args[0].Expression is IdentifierNameSyntax fnParam && + helperContext.ResolveParameter(fnParam.Identifier.ValueText) is { } fnResolved) + { + fnName = TryResolveStringLiteral(fnResolved.Expression, fnResolved.Model); + } + if (fnName is not null) + { + var fnArgs = new List(); + for (var i = 1; i < args.Count; i++) + { + var fa = ParseCanonicalExpression(args[i].Expression, semanticModel, diagnostics, helperContext); + if (fa is null) return null; + fnArgs.Add(fa); + } + return new FunctionExpr(fnName, fnArgs); + } + break; + } + case "Prop": + { + // WorkflowExpr.Prop("name", expr) — used as a standalone expression + // in params WorkflowNamedExpressionDefinition[] contexts. Parse it + // here so parameter-substituted Prop calls resolve correctly. + if (args.Count == 2) + { + var named = ParseNamedExpr(inv, semanticModel, diagnostics, helperContext); + if (named is not null) + { + // Wrap as a NamedExprWrapper so the caller (e.g. Obj) can consume it. + // However, this case only fires when Prop appears as a canonical expression + // argument — callers handle it via ParseNamedExpr. Leave as null. + } + } + break; } - break; } diagnostics.Add(UnsupportedDiagnostic(expr, @@ -1171,35 +3364,112 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator return null; } + /// + /// Expands a conditional-ternary spread element such as + /// .. (includeFoo ? [Prop(...), Prop(...)] : []) at compile time. The condition must + /// resolve to a boolean via literals, const fields, or helper-parameter forwarding. When the + /// chosen branch is an empty collection, nothing is added. Returns true on success (and appends + /// to ); returns false when the shape isn't a conditional-over-collection + /// or the condition isn't compile-time evaluable — the caller then falls through to the generic + /// "unsupported collection element" diagnostic. + /// + private static bool TryExpandConditionalSpread( + SpreadElementSyntax spread, + SemanticModel semanticModel, + HelperContext? helperContext, + List props, + ImmutableArray.Builder diagnostics) + { + if (spread.Expression is not ConditionalExpressionSyntax ternary) return false; + if (helperContext is null) return false; + + var condValue = TryEvaluateBoolCondition(ternary.Condition, semanticModel, helperContext); + if (condValue is null) return false; + + var branch = condValue.Value ? ternary.WhenTrue : ternary.WhenFalse; + + // Extract the items from whichever collection shape is used — collection expressions, + // array initialisers, or an empty collection expression. + if (branch is CollectionExpressionSyntax coll) + { + foreach (var item in coll.Elements) + { + if (item is ExpressionElementSyntax ee) + { + var named = ParseNamedExpr(ee.Expression, semanticModel, diagnostics, helperContext); + if (named is null) return true; // diagnostic already emitted + props.Add(named); + } + } + return true; + } + if (branch is ArrayCreationExpressionSyntax array && array.Initializer is not null) + { + foreach (var item in array.Initializer.Expressions) + { + var named = ParseNamedExpr(item, semanticModel, diagnostics, helperContext); + if (named is null) return true; + props.Add(named); + } + return true; + } + if (branch is ImplicitArrayCreationExpressionSyntax iarray && iarray.Initializer is not null) + { + foreach (var item in iarray.Initializer.Expressions) + { + var named = ParseNamedExpr(item, semanticModel, diagnostics, helperContext); + if (named is null) return true; + props.Add(named); + } + return true; + } + return false; + } + private static NamedExpr? ParseNamedExpr( ExpressionSyntax expr, SemanticModel semanticModel, - ImmutableArray.Builder diagnostics) + ImmutableArray.Builder diagnostics, + HelperContext? helperContext = null) { - // Expected: WorkflowExpr.Prop("name", ) + // Expected: WorkflowExpr.Prop(, ) if (expr is not InvocationExpressionSyntax inv || inv.Expression is not MemberAccessExpressionSyntax member || (member.Expression as IdentifierNameSyntax)?.Identifier.ValueText != "WorkflowExpr" || member.Name.Identifier.ValueText != "Prop" || - inv.ArgumentList.Arguments.Count != 2 || - inv.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax nameLit || - !nameLit.IsKind(SyntaxKind.StringLiteralExpression)) + inv.ArgumentList.Arguments.Count != 2) { diagnostics.Add(UnsupportedDiagnostic(expr, "object properties must be WorkflowExpr.Prop(\"name\", expr)")); return null; } - var child = ParseCanonicalExpression(inv.ArgumentList.Arguments[1].Expression, semanticModel, diagnostics); + // Name must resolve to a compile-time string. Accept literal, const field, nameof(), + // string.Empty, and helper parameters whose call-site argument is any of those. + var nameExpr = inv.ArgumentList.Arguments[0].Expression; + string? name = TryResolveStringLiteral(nameExpr, semanticModel); + if (name is null && helperContext is not null && nameExpr is IdentifierNameSyntax nameParam && + helperContext.ResolveParameter(nameParam.Identifier.ValueText) is { } nameResolution) + { + name = TryResolveStringLiteral(nameResolution.Expression, nameResolution.Model); + } + if (name is null) + { + diagnostics.Add(UnsupportedDiagnostic(nameExpr, + "WorkflowExpr.Prop name must be a compile-time string (literal, const field, nameof, or helper parameter that maps to one)")); + return null; + } + + var child = ParseCanonicalExpression(inv.ArgumentList.Arguments[1].Expression, semanticModel, diagnostics, helperContext); if (child is null) return null; - return new NamedExpr(nameLit.Token.ValueText, child); + return new NamedExpr(name, child); } private static GeneratorDiagnostic UnsupportedDiagnostic(SyntaxNode node, string reason) { return new GeneratorDiagnostic( WorkflowDiagnostics.GeneratorUnsupportedPattern, - node.GetLocation().ToString(), + node.GetLocation(), reason); } @@ -1209,10 +3479,17 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator { ctx.ReportDiagnostic(Diagnostic.Create( d.Descriptor, - location: null, + d.SyntaxLocation, d.Message)); } + // Sentinel: Transform failed on this class; diagnostics are already reported + // above. Do not emit a per-workflow bundle (it would be a broken const). + if (string.IsNullOrEmpty(candidate.ClassName)) + { + return; + } + var className = "_BundledCanonicalWorkflow_" + candidate.ClassName; var sb = new StringBuilder(); sb.AppendLine("// — do not edit; produced by StellaOps.Workflow.Analyzer"); @@ -1264,6 +3541,13 @@ public sealed class WorkflowCanonicalArtifactGenerator : IIncrementalGenerator sb.AppendLine(" {"); foreach (var wf in workflows) { + // Skip sentinel candidates that failed Transform — EmitForWorkflow did + // not produce a _BundledCanonicalWorkflow_ class for them. + if (string.IsNullOrEmpty(wf.ClassName)) + { + continue; + } + sb.AppendLine($" new Entry(_BundledCanonicalWorkflow_{wf.ClassName}.WorkflowName, _BundledCanonicalWorkflow_{wf.ClassName}.WorkflowVersion, _BundledCanonicalWorkflow_{wf.ClassName}.DisplayName, _BundledCanonicalWorkflow_{wf.ClassName}.CanonicalDefinitionJson, _BundledCanonicalWorkflow_{wf.ClassName}.CanonicalContentHash),"); } sb.AppendLine(" };"); diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalityAnalyzer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalityAnalyzer.cs index aed0de84d..661cb882a 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalityAnalyzer.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowCanonicalityAnalyzer.cs @@ -107,6 +107,22 @@ public sealed class WorkflowCanonicalityAnalyzer : DiagnosticAnalyzer return cached; } + // Compile-time construction factory exemption: + // Helper methods whose return type is a workflow builder / expression / spec / + // invocation-declaration / task-definition type (from a trusted workflow assembly) + // are compile-time metaprogramming — they execute once at class-construction time + // to produce a static WorkflowExpr tree. The resulting tree IS canonical; the C# + // constructs in the method body (if, for, foreach, ??, throw, LINQ, List, etc.) + // are construction-time logic, not runtime workflow control flow. + // Also exempt: void helpers that take a WorkflowFlowBuilder parameter (these + // are flow-construction delegates invoked during Spec construction). + if (IsCompileTimeConstructionFactory(helper)) + { + var empty = new List(); + helperCache[helper] = empty; + return empty; + } + var collected = new List(); helperCache[helper] = collected; @@ -143,6 +159,42 @@ public sealed class WorkflowCanonicalityAnalyzer : DiagnosticAnalyzer return collected; } + + private static bool IsCompileTimeConstructionFactory(IMethodSymbol method) + { + // Compile-time construction factory exemption: + // Helper methods whose return type is from a trusted workflow assembly + // (as defined by WorkflowWellKnownTypes.TrustedAssemblyPrefixes) are + // compile-time metaprogramming — they execute once at class-construction + // time to produce a static WorkflowExpr tree. The resulting tree IS + // canonical; the C# constructs in the method body (if, for, foreach, ??, + // throw, LINQ, List, etc.) are construction-time logic, not runtime + // workflow control flow. + if (!method.ReturnsVoid && method.ReturnType is INamedTypeSymbol returnType) + { + if (WorkflowWellKnownTypes.IsTrustedAssembly(returnType.OriginalDefinition.ContainingAssembly)) + { + return true; + } + } + + // A void method taking a WorkflowFlowBuilder parameter (from a trusted + // assembly) is a flow-construction delegate invoked during Spec construction. + if (method.ReturnsVoid) + { + foreach (var param in method.Parameters) + { + if (param.Type is INamedTypeSymbol paramType && + paramType.Name.Contains("FlowBuilder") && + WorkflowWellKnownTypes.IsTrustedAssembly(paramType.OriginalDefinition.ContainingAssembly)) + { + return true; + } + } + } + + return false; + } } internal readonly struct ReachableViolation diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowDiagnostics.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowDiagnostics.cs index d4c17d21e..b6cc0c73b 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowDiagnostics.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Analyzer/WorkflowDiagnostics.cs @@ -74,7 +74,7 @@ internal static class WorkflowDiagnostics category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "The analyzer accepted this code as canonicalizable but the source generator has no rule for this shape yet. File the exact pattern so it can be added, or rewrite using a supported shape. See docs/modules/workflow/analyzer.md for the list of supported builder methods and WorkflowExpr.* variants.", + description: "The analyzer accepted this code as canonicalizable but the source generator has no emit rule for this builder shape yet. The workflow compiles and runs correctly at runtime; only the pre-bundled canonical JSON for the publisher is affected. See docs/workflow/analyzer.md for the list of supported patterns.", helpLinkUri: HelpUri + "#wf020"); public static readonly DiagnosticDescriptor ReachableHelperViolation = new( @@ -84,6 +84,6 @@ internal static class WorkflowDiagnostics category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "A method reachable from workflow code, whether in this project or a project reference, must itself be canonicalizable. The referenced helper violates one of the WF001-WF005 rules.", + description: "A method reachable from workflow code contains a non-canonical C# construct (if, for, throw, LINQ, etc.). NOTE: Helper methods whose return type is from a trusted workflow assembly (per TrustedAssemblyPrefixes) are EXEMPT — they are compile-time construction factories whose body is metaprogramming, not runtime workflow logic. If you see this diagnostic, the helper either returns a non-workflow type or is in a non-trusted assembly.", helpLinkUri: HelpUri + "#wf010"); } diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/AnalyzerTestHarness.cs b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/AnalyzerTestHarness.cs index 475ca1314..8da8f4a8b 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/AnalyzerTestHarness.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/AnalyzerTestHarness.cs @@ -75,6 +75,12 @@ internal static class AnalyzerTestHarness public static Task<(ImmutableArray Diagnostics, ImmutableArray GeneratedSources)> RunGeneratorAsync( string source, params (string Name, string Source)[] additionalSources) + => RunGeneratorAsync(source, additionalSources, System.Array.Empty<(string, string)>()); + + public static Task<(ImmutableArray Diagnostics, ImmutableArray GeneratedSources)> RunGeneratorAsync( + string source, + (string Name, string Source)[] additionalSources, + (string Path, string Content)[] additionalTexts) { var trees = new System.Collections.Generic.List { @@ -93,7 +99,12 @@ internal static class AnalyzerTestHarness nullableContextOptions: NullableContextOptions.Enable)); var generator = new WorkflowCanonicalArtifactGenerator(); - var driver = CSharpGeneratorDriver.Create(generator) + var additionalTextList = additionalTexts + .Select(t => (AdditionalText)new InMemoryAdditionalText(t.Path, t.Content)) + .ToImmutableArray(); + var driver = CSharpGeneratorDriver.Create( + generators: ImmutableArray.Create(generator.AsSourceGenerator()), + additionalTexts: additionalTextList) .RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); var result = driver.GetRunResult(); @@ -103,4 +114,17 @@ internal static class AnalyzerTestHarness return Task.FromResult((diagnostics, generatedSources)); } + + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly string content; + public InMemoryAdditionalText(string path, string content) + { + Path = path; + this.content = content; + } + public override string Path { get; } + public override Microsoft.CodeAnalysis.Text.SourceText? GetText(System.Threading.CancellationToken cancellationToken = default) + => Microsoft.CodeAnalysis.Text.SourceText.From(content); + } } diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorStepsTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorStepsTests.cs index 3c23da8e0..ffd7b7237 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorStepsTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorStepsTests.cs @@ -196,7 +196,7 @@ public class GeneratorStepsTests public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; public WorkflowSpec Spec { get; } = WorkflowSpec.For() - .StartWith(flow => flow.Fork("f", branch => branch.Complete())) + .StartWith(flow => flow.Repeat("r", 3, null, WorkflowExpr.Bool(true), body => body.Complete())) .Build(); } """; @@ -204,4 +204,558 @@ public class GeneratorStepsTests var (diagnostics, _) = await AnalyzerTestHarness.RunGeneratorAsync(source); diagnostics.Select(d => d.Id).Should().Contain("WF020"); } + + [Test] + public async Task WhenStateEquals_WithStringLiteral_DesugarsToDecisionStep() + { + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .WhenStateEquals( + "status", + "approved", + "StatusApproved?", + whenTrue => whenTrue.ActivateTask("Proceed"), + whenElse => whenElse.Complete())) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"decision\\\""); + wf.Should().Contain("\\\"StatusApproved?\\\""); + wf.Should().Contain("\\\"state.status\\\""); + wf.Should().Contain("\\\"approved\\\""); + wf.Should().Contain("\\\"Proceed\\\""); + } + + [Test] + public async Task WhenPayloadEquals_WithNumericLiteral_DesugarsToDecisionStep() + { + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public sealed class R { public int Kind { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .WhenPayloadEquals( + "kind", + 42, + "Is42?", + whenTrue => whenTrue.Complete())) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"decision\\\""); + wf.Should().Contain("\\\"Is42?\\\""); + wf.Should().Contain("\\\"payload.kind\\\""); + wf.Should().Contain("\\\"42\\\""); + } + + [Test] + public async Task SubWorkflow_WithWorkflowReference_EmitsSubWorkflowStep() + { + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + private static readonly WorkflowReference ChildRef = new("ChildWorkflow"); + + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .SubWorkflow("RunChild", ChildRef, "childResult") + .Complete()) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"sub-workflow\\\""); + wf.Should().Contain("\\\"RunChild\\\""); + wf.Should().Contain("\\\"ChildWorkflow\\\""); + wf.Should().Contain("\\\"childResult\\\""); + } + + [Test] + public async Task LazyFragment_FromAdditionalFiles_InlinesCanonicalJson() + { + // A workflow references a static readonly Lazy initialized + // from LoadFragment("...json"). The generator should read the JSON from AdditionalFiles, + // parse it as canonical IR, and inline the result into the workflow's canonical output. + const string workflowSource = """ + using System; + using System.Collections.Generic; + using System.Reflection; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public static class FragmentLoader + { + // Signature that our generator recognises: Lazy(() => LoadFragment("x.json")). + public static T LoadFragment(string name) where T : class + => throw new NotImplementedException("runtime-only; generator inlines the fragment"); + } + + public static class Templates + { + public static readonly Lazy PayloadExpr = + new(() => FragmentLoader.LoadFragment("payload.json")); + + public static WorkflowExpressionDefinition GetPayload() => PayloadExpr.Value; + } + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow.Set("derived", Templates.GetPayload()).Complete()) + .Build(); + } + """; + + const string fragmentJson = """ + { + "$type": "object", + "properties": [ + { "name": "ProductCode", "expression": { "$type": "path", "path": "state.productCode" } }, + { "name": "Label", "expression": { "$type": "string", "value": "fallback" } } + ] + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync( + workflowSource, + additionalSources: System.Array.Empty<(string, string)>(), + additionalTexts: new[] { ("payload.json", fragmentJson) }); + + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"ProductCode\\\"", + "fragment's object property name must appear in the generated canonical JSON"); + wf.Should().Contain("\\\"state.productCode\\\"", + "fragment's path expression must be inlined"); + wf.Should().Contain("\\\"fallback\\\"", + "fragment's nested string expression must be inlined"); + } + + [Test] + public async Task SpecHelper_InitializedFromHelper_WalksHelperBody() + { + // Workflow whose Spec property is assigned from a static helper method that + // returns a WorkflowSpec. The generator should inline the helper and walk + // the WorkflowSpec.For()...Build() chain as if it were declared inline. + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public sealed class R { public string? Id { get; set; } } + + public static class SpecBuilders + { + public static WorkflowSpec BuildSimpleSpec(string markerKey) + { + return WorkflowSpec.For() + .StartWith(flow => flow + .Set(markerKey, WorkflowExpr.String("hello")) + .Complete()) + .Build(); + } + } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => Spec.TaskDescriptors; + public WorkflowSpec Spec { get; } = SpecBuilders.BuildSimpleSpec("greeting"); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"set-state\\\""); + wf.Should().Contain("\\\"greeting\\\"", + "the call-site string literal must substitute into the helper's markerKey parameter"); + wf.Should().Contain("\\\"hello\\\""); + wf.Should().Contain("\\\"complete\\\""); + } + + [Test] + public async Task FluentHelper_PureChain_InlinesAsMultipleSteps() + { + // A user-defined extension method that returns the flow builder after chaining + // .Set calls should be inlined: the workflow's canonical JSON should contain the + // three set-state steps, not a fluent-helper warning. + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public static class Helpers + { + public static WorkflowFlowBuilder ApplyDefaults( + this WorkflowFlowBuilder flow, string resultKey) + where R : class + { + return flow + .Set("a", WorkflowExpr.Path($"result.{resultKey}.a")) + .Set("b", WorkflowExpr.Path($"result.{resultKey}.b")) + .Set("c", WorkflowExpr.Path($"result.{resultKey}.c")); + } + } + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow.ApplyDefaults("productInfo").Complete()) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"result.productInfo.a\\\""); + wf.Should().Contain("\\\"result.productInfo.b\\\""); + wf.Should().Contain("\\\"result.productInfo.c\\\""); + } + + [Test] + public async Task FluentHelper_WithConditionalBlock_IncludesOnlyTakenBranch() + { + // A helper with `if (someBool) flow.Set(...);` must evaluate the condition against the + // call-site argument and include/exclude the step accordingly. + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public static class Helpers + { + public static WorkflowFlowBuilder ApplyConditional( + this WorkflowFlowBuilder flow, string resultKey, bool includeExtra = false) + where R : class + { + flow.Set("always", WorkflowExpr.Path($"result.{resultKey}.always")); + if (includeExtra) + { + flow.Set("extra", WorkflowExpr.Path($"result.{resultKey}.extra")); + } + return flow; + } + } + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .ApplyConditional("k", includeExtra: true) + .Complete()) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"result.k.always\\\""); + wf.Should().Contain("\\\"result.k.extra\\\"", + "the call site passed includeExtra: true, so the conditional branch must be inlined"); + } + + [Test] + public async Task FluentHelper_WithFalseConditional_OmitsBranch() + { + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public static class Helpers + { + public static WorkflowFlowBuilder ApplyConditional( + this WorkflowFlowBuilder flow, string resultKey, bool includeExtra = false) + where R : class + { + flow.Set("always", WorkflowExpr.Path($"result.{resultKey}.always")); + if (includeExtra) + { + flow.Set("extra", WorkflowExpr.Path($"result.{resultKey}.extra")); + } + return flow; + } + } + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow.ApplyConditional("k").Complete()) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"result.k.always\\\""); + wf.Should().NotContain("\\\"result.k.extra\\\"", + "the call site used the default includeExtra=false, so the branch must be omitted"); + } + + [Test] + public async Task ParseCanonicalExpression_MultiHopHelper_ResolvesInterpolatedPath() + { + // Helper B forwards its `k` parameter to helper A. A's body uses the parameter in + // an interpolated path: WorkflowExpr.Path($"result.{k}.id"). Without chained + // parameter resolution, the inner substitution leaves `k` bound to A's own + // parameter identifier (not a literal) and the Path case can't finalize. + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public static class Helpers + { + public static WorkflowExpressionDefinition InnerBuildPath(string k) + => WorkflowExpr.Path($"result.{k}.id"); + + public static WorkflowExpressionDefinition OuterBuildPath(string k) + => InnerBuildPath(k); + } + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .Set("derived", Helpers.OuterBuildPath("payload")) + .Complete()) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"result.payload.id\\\"", + "interpolated path must unwrap through two helper hops"); + } + + [Test] + public async Task WorkflowExprString_WithStringEmpty_ResolvesToEmptyString() + { + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .Set("fallback", WorkflowExpr.String(string.Empty)) + .Complete()) + .Build(); + } + """; + + var (diagnostics, _) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + } + + [Test] + public async Task Call_WithWorkflowHandledBranchActionComplete_DesugarsToCompleteBranch() + { + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + private static readonly LegacyRabbitAddress QueryAddress = new("pas_query"); + + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .Call( + "Query", + QueryAddress, + WorkflowExpr.Obj(WorkflowExpr.Prop("Id", WorkflowExpr.Path("start.id"))), + WorkflowHandledBranchAction.Complete, + WorkflowHandledBranchAction.Complete, + resultKey: "payload")) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"call-transport\\\""); + wf.Should().Contain("\\\"whenFailure\\\""); + wf.Should().Contain("\\\"whenTimeout\\\""); + wf.Should().Contain("\\\"complete\\\""); + wf.Should().Contain("\\\"payload\\\""); + } + + [Test] + public async Task Fork_WithTwoBranches_EmitsForkStep() + { + const string source = """ + using System; + using System.Collections.Generic; + using StellaOps.Workflow.Abstractions; + using StellaOps.Workflow.Contracts; + + public sealed class R { public string? Id { get; set; } } + + public sealed class W : IDeclarativeWorkflow + { + public string WorkflowName => "W"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "W"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .StartWith(flow => flow + .Fork( + "ParallelWork", + branchA => branchA.ActivateTask("TaskA"), + branchB => branchB.ActivateTask("TaskB"))) + .Build(); + } + """; + + var (diagnostics, generatedSources) = await AnalyzerTestHarness.RunGeneratorAsync(source); + diagnostics.Where(d => d.Id.StartsWith("WF")).Should().BeEmpty(); + + var wf = generatedSources.First(s => s.HintName == "_BundledCanonicalWorkflow_W.g.cs").SourceText.ToString(); + wf.Should().Contain("\\\"fork\\\""); + wf.Should().Contain("\\\"ParallelWork\\\""); + wf.Should().Contain("\\\"TaskA\\\""); + wf.Should().Contain("\\\"TaskB\\\""); + } } diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/NonTrustedCallTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/NonTrustedCallTests.cs index 0a6677825..88657a17f 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/NonTrustedCallTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/NonTrustedCallTests.cs @@ -68,8 +68,12 @@ public class NonTrustedCallTests } [Test] - public async Task CallingHelperThatHasImperative_SurfacesWF010AtCallSite() + public async Task CallingHelperReturningWorkflowType_ExemptFromWF010() { + // Helpers that return a trusted workflow type (WorkflowSpec, WorkflowExpressionDefinition, + // etc.) are compile-time construction factories. Their imperative C# (if, for, List, etc.) + // executes once at class-construction time to produce a static WorkflowExpr tree. + // The analyzer exempts them from WF010. var source = WorkflowSourceBuilder.Preamble + """ public static class ImpureHelper @@ -95,6 +99,8 @@ public class NonTrustedCallTests """; var diagnostics = await AnalyzerTestHarness.RunAsync(source); - diagnostics.Select(d => d.Id).Should().Contain("WF010"); + var ids = diagnostics.Select(d => d.Id).Where(id => id.StartsWith("WF")).ToArray(); + ids.Should().NotContain("WF010", + "helpers returning trusted workflow types are compile-time construction factories — exempt from WF010"); } }