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