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:
master
2026-04-18 17:42:09 +03:00
parent bc6b1c5959
commit fd689748c9
8 changed files with 3564 additions and 81 deletions

View File

@@ -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) { }
}

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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");
}

View File

@@ -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);
}
}

View File

@@ -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\\\"");
}
}

View File

@@ -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");
}
}