feat(workflow): byte-parity test harness + pinned LF newline (pending option A vs B)

Wires the analyzer + source generator into the analyzer test project
so parity tests can compare the generator's bundled JSON against what
the runtime WorkflowCanonicalDefinitionCompiler produces for the same
workflow class instance.

Changes:

* WorkflowCanonicalJsonSerializer.SerializerOptions: explicitly pin
  `NewLine = "\n"` so canonical-JSON bytes are identical across build
  platforms. Default would be Environment.NewLine = \r\n on Windows,
  \n on Unix — unstable for hash-dedup across CI runners.
* Test project now consumes StellaOps.Workflow.Analyzer via <Analyzer>
  item with a pre-build MSBuild Target that builds the analyzer csproj
  first, so generator output is available in-assembly.
* New Fixtures/ParityFixtureWorkflows.cs with two canonical fixtures
  exercising expression-only + step/task builder chains.
* New GeneratorByteParityTests.cs with diagnostic output showing the
  exact byte-offset of first drift + visible-char window around it.

Parity tests are marked [Explicit] because the runtime compiler
populates `startRequest`, `businessReference`, and `requiredModules`
via CLR reflection — fields the generator does not yet emit
(replicating them symbolically requires significant work). The drift
surface is documented in the test class doc comment.

The next architectural decision is captured in the plan file: either
(A) extend the generator to reimplement those reflection paths
symbolically, or (B) pivot to a hybrid where the generator emits
metadata + type registry and the publisher calls the runtime
compiler at startup. Option B eliminates the parity gap entirely
with publisher overhead of ~1-5 ms per workflow on first boot.

Test status: 36 passing, 2 explicit-skipped (parity).
This commit is contained in:
master
2026-04-15 12:07:17 +03:00
parent cbfdd0e96c
commit a2c9098dc8
4 changed files with 194 additions and 0 deletions

View File

@@ -331,6 +331,11 @@ public static class WorkflowCanonicalJsonSerializer
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
// Pin LF so canonical-JSON bytes are identical across build platforms
// (default would be Environment.NewLine = \r\n on Windows, \n on Unix).
// The compile-time source generator in StellaOps.Workflow.Analyzer must
// produce byte-identical output; that depends on a stable newline.
NewLine = "\n",
};
public static JsonSerializerOptions Options => SerializerOptions;

View File

@@ -0,0 +1,64 @@
using System.Collections.Generic;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Analyzer.Tests.Fixtures;
public sealed class ParityFixtureRequest
{
public string? PolicyId { get; set; }
public string? Status { get; set; }
}
/// <summary>
/// Minimal-but-valid: InitializeState + trivial StartWith(flow => flow.Complete()).
/// </summary>
public sealed class PureExpressionWorkflow : IDeclarativeWorkflow<ParityFixtureRequest>
{
public string WorkflowName => "PureExpressionWorkflow";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Pure Expression Workflow";
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
public WorkflowSpec<ParityFixtureRequest> Spec { get; } = WorkflowSpec.For<ParityFixtureRequest>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
WorkflowExpr.Prop("status", WorkflowExpr.Func(
"coalesce",
WorkflowExpr.Path("start.status"),
WorkflowExpr.String("new"))),
WorkflowExpr.Prop("priority", WorkflowExpr.Number(1))))
.StartWith(flow => flow.Complete())
.Build();
}
/// <summary>
/// Exercises StartWith + Set + ActivateTask + Complete inside a decision +
/// a matching AddTask so the runtime compiler accepts the reference.
/// </summary>
public sealed class StartWithDecisionWorkflow : IDeclarativeWorkflow<ParityFixtureRequest>
{
public string WorkflowName => "StartWithDecisionWorkflow";
public string WorkflowVersion => "1.0.0";
public string DisplayName => "Start With Decision";
public IReadOnlyCollection<string> WorkflowRoles => new string[0];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => new WorkflowTaskDescriptor[0];
public WorkflowSpec<ParityFixtureRequest> Spec { get; } = WorkflowSpec.For<ParityFixtureRequest>()
.InitializeState(WorkflowExpr.Obj(
WorkflowExpr.Prop("pid", WorkflowExpr.Path("start.policyId"))))
.StartWith(flow => flow
.Set("pid", WorkflowExpr.Path("start.policyId"))
.WhenExpression(
"Approved?",
WorkflowExpr.Eq(WorkflowExpr.Path("start.status"), WorkflowExpr.String("approved")),
whenTrue => whenTrue.ActivateTask("Process"),
whenElse => whenElse.Complete()))
.AddTask(WorkflowHumanTask.For<ParityFixtureRequest>("Process", "Review", "default")
.WithRoles("APR")
.WithPayload(WorkflowExpr.Obj(
WorkflowExpr.Prop("pid", WorkflowExpr.Path("state.pid"))))
.OnComplete(flow => flow.Complete()))
.Build();
}

View File

@@ -0,0 +1,110 @@
using System.Reflection;
using FluentAssertions;
using NUnit.Framework;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Analyzer.Tests.Fixtures;
namespace StellaOps.Workflow.Analyzer.Tests;
/// <summary>
/// Generator output vs runtime compiler output byte-parity.
///
/// Current status: PARTIAL. Shared top-level fields (schemaVersion,
/// workflowName, workflowVersion, displayName) plus emitted start/tasks
/// content match byte-for-byte. Divergent:
/// * startRequest — runtime builds the full JSON schema of TStartRequest
/// via CLR reflection (BuildStartRequestContract). Replicating via
/// Roslyn ISymbol walks is significant work and not trivially byte-exact.
/// * businessReference — runtime inspects [WorkflowBusinessKey]-style
/// attributes on TStartRequest. Same reflection-vs-symbol issue.
/// * requiredModules — inferred by the runtime from the function catalog.
/// * Nested object/array brace formatting — runtime puts `{`/`[` on the
/// same line as the preceding `:`, whereas the hand-rolled writer
/// currently puts them on a new line with indent.
///
/// Options under consideration (see plan file, risk #1):
/// A) Extend the generator to emit startRequest/businessReference/
/// requiredModules and fix the brace formatting. Big engineering cost,
/// ongoing maintenance burden to track runtime-compiler changes.
/// B) Pivot: publisher calls WorkflowCanonicalDefinitionCompiler.Compile()
/// at startup. Publisher already runs in net10.0 with full runtime
/// access. The generator stays for validation + registry +
/// metadata-as-const, but the actual canonical JSON upload uses the
/// runtime compiler. Zero parity concerns.
///
/// These tests stay as [Explicit] until the path is chosen — they document
/// the drift surface precisely and can be flipped on once option A is
/// implemented.
/// </summary>
[TestFixture]
public class GeneratorByteParityTests
{
[Test]
[Explicit("Parity pending option A vs B decision — see class doc comment")]
public void PureExpressionWorkflow_GeneratorJson_MatchesRuntimeCompilerJson()
{
AssertParity<PureExpressionWorkflow>(
generatedTypeFullName: "StellaOps.Workflow.Generated._BundledCanonicalWorkflow_PureExpressionWorkflow");
}
[Test]
[Explicit("Parity pending option A vs B decision — see class doc comment")]
public void StartWithDecisionWorkflow_GeneratorJson_MatchesRuntimeCompilerJson()
{
AssertParity<StartWithDecisionWorkflow>(
generatedTypeFullName: "StellaOps.Workflow.Generated._BundledCanonicalWorkflow_StartWithDecisionWorkflow");
}
private static void AssertParity<TWorkflow>(string generatedTypeFullName)
where TWorkflow : new()
{
var instance = new TWorkflow();
var result = WorkflowCanonicalDefinitionCompiler.Compile(
(IDeclarativeWorkflow<ParityFixtureRequest>)(object)instance);
result.Succeeded.Should().BeTrue("runtime compiler must succeed on the fixture workflow");
result.Definition.Should().NotBeNull();
var runtimeJson = WorkflowCanonicalJsonSerializer.Serialize(result.Definition!);
var generatedType = typeof(TWorkflow).Assembly.GetType(generatedTypeFullName, throwOnError: false, ignoreCase: false);
generatedType.Should().NotBeNull(
$"the source generator must have emitted {generatedTypeFullName} into the test assembly");
var jsonField = generatedType!.GetField(
"CanonicalDefinitionJson",
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
jsonField.Should().NotBeNull("generator must emit CanonicalDefinitionJson const");
var generatedJson = (string)jsonField!.GetValue(null)!;
if (generatedJson != runtimeJson)
{
var offset = FindFirstDiffOffset(runtimeJson, generatedJson);
var window = 120;
var start = System.Math.Max(0, offset - 40);
var runtimeEnd = System.Math.Min(runtimeJson.Length, start + window);
var genEnd = System.Math.Min(generatedJson.Length, start + window);
var runtimeWindow = runtimeJson.Substring(start, runtimeEnd - start);
var generatedWindow = generatedJson.Substring(start, genEnd - start);
Assert.Fail(
$"Generator JSON does not match runtime compiler JSON. First diff at offset {offset} (runtime={runtimeJson.Length} bytes, generated={generatedJson.Length} bytes).\n\n" +
"--- Runtime window ---\n" +
Visible(runtimeWindow) + "\n\n" +
"--- Generated window ---\n" +
Visible(generatedWindow));
}
}
private static string Visible(string s) =>
s.Replace("\n", "\\n\n").Replace("\r", "\\r").Replace("\t", "\\t");
private static int FindFirstDiffOffset(string a, string b)
{
var n = System.Math.Min(a.Length, b.Length);
for (var i = 0; i < n; i++)
{
if (a[i] != b[i]) return i;
}
return n;
}
}

View File

@@ -21,8 +21,23 @@
</ItemGroup>
<ItemGroup>
<!-- Plain project reference so the analyzer types are callable from test code. -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Analyzer\StellaOps.Workflow.Analyzer.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj" />
</ItemGroup>
<!--
Run the analyzer + generator against this test project's own source so
the parity tests can compare the generator's bundled JSON against what
the runtime compiler produces for the same workflow class instance.
-->
<Target Name="_BuildWorkflowAnalyzerForTests" BeforeTargets="BeforeBuild">
<MSBuild Projects="..\..\__Libraries\StellaOps.Workflow.Analyzer\StellaOps.Workflow.Analyzer.csproj"
Properties="Configuration=$(Configuration)" />
</Target>
<ItemGroup>
<Analyzer Include="..\..\__Libraries\StellaOps.Workflow.Analyzer\bin\$(Configuration)\netstandard2.0\StellaOps.Workflow.Analyzer.dll" />
</ItemGroup>
</Project>