feat(workflow): analyzer expansion — SubWorkflow/Fork/state+payload guards + helper-context
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<T>. - WorkflowCanonicalityAnalyzer: helpers returning trusted workflow types (WorkflowSpec<T>, 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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Workflow.Analyzer.Emission;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal recursive-descent JSON parser that produces <see cref="CanonicalExpr"/> nodes
|
||||
/// matching the polymorphic <c>$type</c> discriminators used in the runtime contract and in
|
||||
/// serialised canonical fragments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This parser is intentionally narrow. It only accepts the shape that the canonical JSON
|
||||
/// writer itself emits: every expression-object has a <c>$type</c> field and the shape that
|
||||
/// type dictates (see <c>WorkflowExpressionDefinition</c>'s <c>JsonDerivedType</c> attributes).
|
||||
/// Anything else throws <see cref="FragmentParseException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The parser runs inside the Roslyn source generator, which targets netstandard2.0 without
|
||||
/// System.Text.Json available. Hand-rolling keeps dependencies zero.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<string, object?>(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}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Either a canonical expression (when the object has <c>$type</c>) or a plain property
|
||||
/// carrier (used for <c>properties[]</c> entries which are <c>{name, expression}</c>).
|
||||
/// </summary>
|
||||
private static object ReadObjectOrExpression(ref JsonReader reader)
|
||||
{
|
||||
var start = reader.Position;
|
||||
reader.Advance(); // consume '{'
|
||||
var members = new Dictionary<string, object?>(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<string, object?> 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<object?> ReadArray(ref JsonReader reader)
|
||||
{
|
||||
reader.Advance(); // consume '['
|
||||
var items = new List<object?>();
|
||||
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<NamedExpr> ReadProperties(List<object?> raw)
|
||||
{
|
||||
var props = new List<NamedExpr>(raw.Count);
|
||||
foreach (var item in raw)
|
||||
{
|
||||
if (item is not Dictionary<string, object?> 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<CanonicalExpr> CastItems(List<object?> raw)
|
||||
{
|
||||
var items = new List<CanonicalExpr>(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<string, object?> 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<string, object?> 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<object?> RequireArray(Dictionary<string, object?> members, string key)
|
||||
{
|
||||
if (!members.TryGetValue(key, out var value) || value is not List<object?> arr)
|
||||
{
|
||||
throw new FragmentParseException($"expected array member '{key}'");
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static CanonicalExpr RequireExpr(Dictionary<string, object?> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class FragmentParseException : Exception
|
||||
{
|
||||
public FragmentParseException(string message) : base(message) { }
|
||||
}
|
||||
@@ -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<NamedExpr> BusinessReferenceParts { get; }
|
||||
|
||||
public ContinueWithStep(
|
||||
string stepName,
|
||||
CanonicalExpr? workflowNameExpression,
|
||||
CanonicalExpr? workflowVersionExpression,
|
||||
CanonicalExpr? payloadExpression,
|
||||
CanonicalExpr? businessReferenceKeyExpression,
|
||||
IReadOnlyList<NamedExpr> 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<NamedExpr> BusinessReferenceParts { get; }
|
||||
public string? ResultKey { get; }
|
||||
|
||||
public SubWorkflowStep(
|
||||
string stepName,
|
||||
CanonicalExpr? workflowNameExpression,
|
||||
CanonicalExpr? workflowVersionExpression,
|
||||
CanonicalExpr? payloadExpression,
|
||||
CanonicalExpr? businessReferenceKeyExpression,
|
||||
IReadOnlyList<NamedExpr> 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<StepSequence> Branches { get; }
|
||||
|
||||
public ForkStep(string stepName, IReadOnlyList<StepSequence> 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<T>, etc.)
|
||||
// are construction-time logic, not runtime workflow control flow.
|
||||
// Also exempt: void helpers that take a WorkflowFlowBuilder<T> parameter (these
|
||||
// are flow-construction delegates invoked during Spec construction).
|
||||
if (IsCompileTimeConstructionFactory(helper))
|
||||
{
|
||||
var empty = new List<ReachableViolation>();
|
||||
helperCache[helper] = empty;
|
||||
return empty;
|
||||
}
|
||||
|
||||
var collected = new List<ReachableViolation>();
|
||||
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<T>, 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<T> 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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ internal static class AnalyzerTestHarness
|
||||
public static Task<(ImmutableArray<Diagnostic> Diagnostics, ImmutableArray<GeneratedSourceResult> GeneratedSources)> RunGeneratorAsync(
|
||||
string source,
|
||||
params (string Name, string Source)[] additionalSources)
|
||||
=> RunGeneratorAsync(source, additionalSources, System.Array.Empty<(string, string)>());
|
||||
|
||||
public static Task<(ImmutableArray<Diagnostic> Diagnostics, ImmutableArray<GeneratedSourceResult> GeneratedSources)> RunGeneratorAsync(
|
||||
string source,
|
||||
(string Name, string Source)[] additionalSources,
|
||||
(string Path, string Content)[] additionalTexts)
|
||||
{
|
||||
var trees = new System.Collections.Generic.List<SyntaxTree>
|
||||
{
|
||||
@@ -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<ISourceGenerator>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ public class GeneratorStepsTests
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R>
|
||||
{
|
||||
private static readonly WorkflowReference ChildRef = new("ChildWorkflow");
|
||||
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<WorkflowExpressionDefinition> 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<T>(() => LoadFragment<T>("x.json")).
|
||||
public static T LoadFragment<T>(string name) where T : class
|
||||
=> throw new NotImplementedException("runtime-only; generator inlines the fragment");
|
||||
}
|
||||
|
||||
public static class Templates
|
||||
{
|
||||
public static readonly Lazy<WorkflowExpressionDefinition> PayloadExpr =
|
||||
new(() => FragmentLoader.LoadFragment<WorkflowExpressionDefinition>("payload.json"));
|
||||
|
||||
public static WorkflowExpressionDefinition GetPayload() => PayloadExpr.Value;
|
||||
}
|
||||
|
||||
public sealed class R { public string? Id { get; set; } }
|
||||
|
||||
public sealed class W : IDeclarativeWorkflow<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<T>. The generator should inline the helper and walk
|
||||
// the WorkflowSpec.For<T>()...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<R> BuildSimpleSpec(string markerKey)
|
||||
{
|
||||
return WorkflowSpec.For<R>()
|
||||
.StartWith(flow => flow
|
||||
.Set(markerKey, WorkflowExpr.String("hello"))
|
||||
.Complete())
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class W : IDeclarativeWorkflow<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
public WorkflowSpec<R> 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<R> ApplyDefaults<R>(
|
||||
this WorkflowFlowBuilder<R> 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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R> ApplyConditional<R>(
|
||||
this WorkflowFlowBuilder<R> 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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R> ApplyConditional<R>(
|
||||
this WorkflowFlowBuilder<R> 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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R>
|
||||
{
|
||||
private static readonly LegacyRabbitAddress QueryAddress = new("pas_query");
|
||||
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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<R>
|
||||
{
|
||||
public string WorkflowName => "W";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "W";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
|
||||
|
||||
public WorkflowSpec<R> Spec { get; } = WorkflowSpec.For<R>()
|
||||
.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\\\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>, WorkflowExpressionDefinition,
|
||||
// etc.) are compile-time construction factories. Their imperative C# (if, for, List<T>, 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user