From a2c9098dc8fa9b3f6a8a123f6e8a541a1a0a2794 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 15 Apr 2026 12:07:17 +0300 Subject: [PATCH] feat(workflow): byte-parity test harness + pinned LF newline (pending option A vs B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- .../WorkflowCanonicalDefinitionContracts.cs | 5 + .../Fixtures/ParityFixtureWorkflows.cs | 64 ++++++++++ .../GeneratorByteParityTests.cs | 110 ++++++++++++++++++ .../StellaOps.Workflow.Analyzer.Tests.csproj | 15 +++ 4 files changed, 194 insertions(+) create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/Fixtures/ParityFixtureWorkflows.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorByteParityTests.cs diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs index f1e5bb15a..b83560f4b 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowCanonicalDefinitionContracts.cs @@ -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; diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/Fixtures/ParityFixtureWorkflows.cs b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/Fixtures/ParityFixtureWorkflows.cs new file mode 100644 index 000000000..09526aaf1 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/Fixtures/ParityFixtureWorkflows.cs @@ -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; } +} + +/// +/// Minimal-but-valid: InitializeState + trivial StartWith(flow => flow.Complete()). +/// +public sealed class PureExpressionWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "PureExpressionWorkflow"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Pure Expression Workflow"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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(); +} + +/// +/// Exercises StartWith + Set + ActivateTask + Complete inside a decision + +/// a matching AddTask so the runtime compiler accepts the reference. +/// +public sealed class StartWithDecisionWorkflow : IDeclarativeWorkflow +{ + public string WorkflowName => "StartWithDecisionWorkflow"; + public string WorkflowVersion => "1.0.0"; + public string DisplayName => "Start With Decision"; + public IReadOnlyCollection WorkflowRoles => new string[0]; + public IReadOnlyCollection Tasks => new WorkflowTaskDescriptor[0]; + + public WorkflowSpec Spec { get; } = WorkflowSpec.For() + .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("Process", "Review", "default") + .WithRoles("APR") + .WithPayload(WorkflowExpr.Obj( + WorkflowExpr.Prop("pid", WorkflowExpr.Path("state.pid")))) + .OnComplete(flow => flow.Complete())) + .Build(); +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorByteParityTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorByteParityTests.cs new file mode 100644 index 000000000..65be5ee40 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/GeneratorByteParityTests.cs @@ -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; + +/// +/// 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. +/// +[TestFixture] +public class GeneratorByteParityTests +{ + [Test] + [Explicit("Parity pending option A vs B decision — see class doc comment")] + public void PureExpressionWorkflow_GeneratorJson_MatchesRuntimeCompilerJson() + { + AssertParity( + 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( + generatedTypeFullName: "StellaOps.Workflow.Generated._BundledCanonicalWorkflow_StartWithDecisionWorkflow"); + } + + private static void AssertParity(string generatedTypeFullName) + where TWorkflow : new() + { + var instance = new TWorkflow(); + var result = WorkflowCanonicalDefinitionCompiler.Compile( + (IDeclarativeWorkflow)(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; + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj index 1c42c9b40..31be1a476 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj +++ b/src/Workflow/__Tests/StellaOps.Workflow.Analyzer.Tests/StellaOps.Workflow.Analyzer.Tests.csproj @@ -21,8 +21,23 @@ + + + + + + + + + +