elsa-core/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs
Sipke Schoorstra 6a3d65ff7e
feat(bpmn): the work binder and the elsa: binding format (#7946)
* feat(bpmn): bind BPMN work declarations to Elsa activities

Turns the reader's BpmnWorkBinding declarations into the activity nodes a
BpmnProcess scope runs. Six of the seven kinds bind automatically: TimerWait to
Delay, MessageWait/SignalWait to Event, MessagePublish to PublishEvent,
CallProcess to DispatchWorkflow, NestedProcess to a nested BpmnProcess. The
seventh, UnboundTask, is an authoring decision and is read from a new elsa:
vendor extension inside the document, so an exported .bpmn is self-contained.

Every binding for a scope is bound whatever its slot, so a ScopeListener needs
no special case. Each binding gets its own freshly built activity with a
scope-qualified id: ActivityVisitor skips an activity it has already collected,
so one instance shared between two scopes would leave the second scope with no
child in Elsa's identity graph.

The binder lives in Elsa.Bpmn.Interchange because BpmnWorkBinding is a
Bpmn.Interchange type; binding it in Elsa.Bpmn would pull the interchange
library into the execution module's closure, which is the split D12 draws.

Every ambiguity resolves loudly: an unbound task, a dead binding declaration, a
malformed ISO-8601 duration, a call activity with nothing to call, and an
activity type nothing registered all refuse at bind time rather than producing a
process that runs to completion doing none of what the document says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): declare document variables on the bound scope, refuse duplicate input names

BpmnWorkBinder.BindScope never copied BpmnProcessDefinition.Variables onto the
produced BpmnProcess's Elsa Variables, so a document-declared collection variable
was Absent to IBpmnVariableReader and a collection-mode multi-instance over it
faulted the element instead of running once per item. BindScope now declares an
Elsa Variable for each document variable, seeding the declared default as the
JsonElement it already is.

BpmnActivityBindingFormat.Read silently let a second <elsa:input name="..."> with
a duplicate name overwrite the first rather than refusing it, unlike every other
malformed-document case this binder already refuses. It now throws
BpmnBindingException naming the binding and the duplicated input, and the XML doc
now states that rule plus the (verified) XML text-node escaping that already
applies to input JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): carry every declared activity input through the binding format

BpmnActivityBindingFormat.Write only found properties whose CLR type derives
from Input, silently dropping attribute-declared inputs like Switch.Cases from
an export. Read accepted any <elsa:input name="..."> without checking the
activity declares it, so a mistyped or stale name imported silently with the
configuration missing since Elsa's deserializer ignores unknown members. Both
now go through IActivityDescriber.GetInputProperties, the same enumeration
ActivityDescriptor.Inputs is built from, so Write and Read agree on what an
activity's inputs are and Read refuses a name that enumeration does not
report.

Also makes BpmnWorkBinder.RefuseUnusedDeclarations filter its loop explicitly
with .Where(...) instead of an implicit if, per static analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): filter undeclared input names explicitly

Express the undeclared-input-name check as an explicit Where filter
instead of an implicit filter inside the loop body, and report every
undeclared name at once rather than only the first. Also fix the
refusal message, which previously named the activity type twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(bpmn): describe the input payload shape accurately

The XML doc on BpmnActivityBindingFormat claimed every <elsa:input> is the
{"typeName":...,"expression":...} wrapper a stored workflow definition uses.
That only holds for Input<T>-typed properties: an [Input]-attributed
plain-typed property such as Switch.Cases is serialized as its own JSON
shape (an array), not the wrapper, which Write already does correctly and
the round-trip test already covers. Correct the doc to describe the payload
as the configured activity serializer's output for that input, dependent on
how the activity declares it, and add a second short example showing the
attribute-declared shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:38:03 +02:00

118 lines
6.7 KiB
C#

using System.Reflection;
using System.Runtime.CompilerServices;
using Bpmn.Interchange;
using Bpmn.Model;
using Bpmn.Semantics;
using Elsa.Bpmn.Features;
using Elsa.Bpmn.Interchange.Features;
namespace Elsa.Bpmn.Interchange.UnitTests;
/// <summary>
/// Guards D12 (see #7909/#7934): no type under the Elsa.Bpmn* assemblies may reimplement BPMN
/// semantics that the Bpmn.* library already provides. This has already happened once, in
/// elsa-foundation, where a parallel BpmnElement/BpmnGraph/etc. semantics core grew alongside the
/// shared library because only the interchange half was migrated. A review habit did not catch
/// that; this test exists to.
/// This guard lives in the interchange test project, not the Elsa.Bpmn one, because it is the only
/// project that sees both Elsa.Bpmn and Elsa.Bpmn.Interchange transitively without inverting the
/// module layering that the guard itself protects.
/// The dependency direction (that Elsa.Bpmn/Elsa.Bpmn.Interchange still reference the Bpmn.*
/// packages rather than dropping them and keeping a local copy) is not asserted at runtime here;
/// it is enforced at compile time by the typeof bindings below, see the comment there.
/// This guard also proves its own detector: a fixture type below deliberately collides with a
/// library type name so the suite shows the check can fail, not just pass.
/// </summary>
public class BpmnLibraryDuplicationGuardTests
{
// Every type name the assembly defines is checked, not just public ones: an internal
// reimplementation of BpmnGraph is the same disease as a public one.
// These typeof bindings are load-bearing beyond just locating the assemblies below: dropping
// the Bpmn.Semantics package reference from Elsa.Bpmn, or the Bpmn.Interchange package
// reference from Elsa.Bpmn.Interchange, fails this project's build outright (verified:
// CS0234), because typeof(BpmnInterpreter) and typeof(BpmnXmlReader) respectively have
// nowhere else to resolve from. typeof(BpmnDefinitions) pins Bpmn.Model the same way, though
// Bpmn.Model also arrives transitively via Bpmn.Semantics and Bpmn.Interchange, so dropping
// only its own package reference from Elsa.Bpmn does not by itself break the build; the
// binding is kept for symmetry and because BpmnDefinitions itself must still be reachable for
// the guard's collision check to run. This compile-time enforcement is stronger than a
// runtime assertion could be where it applies, so no test asserts the dependency direction.
// Do not remove these bindings or replace them with string-based assembly lookup.
private static readonly Assembly BpmnModelAssembly = typeof(BpmnDefinitions).Assembly;
private static readonly Assembly BpmnSemanticsAssembly = typeof(BpmnInterpreter).Assembly;
private static readonly Assembly BpmnInterchangeAssembly = typeof(BpmnXmlReader).Assembly;
private static readonly Assembly ElsaBpmnAssembly = typeof(BpmnFeature).Assembly;
private static readonly Assembly ElsaBpmnInterchangeAssembly = typeof(BpmnInterchangeFeature).Assembly;
[Fact]
public void ElsaBpmnAssembly_DoesNotDuplicateBpmnModelOrBpmnSemanticsTypeNames()
{
AssertNoTypeNameCollisions(ElsaBpmnAssembly, BpmnModelAssembly, BpmnSemanticsAssembly);
}
[Fact]
public void ElsaBpmnInterchangeAssembly_DoesNotDuplicateBpmnLibraryTypeNames()
{
// Bpmn.Interchange owns BpmnXmlReader/BpmnXmlWriter and the import analyzer per the
// library/module split, so its type names are forbidden here too, alongside Bpmn.Model
// and Bpmn.Semantics (which Elsa.Bpmn.Interchange can reach transitively).
AssertNoTypeNameCollisions(ElsaBpmnInterchangeAssembly, BpmnModelAssembly, BpmnSemanticsAssembly, BpmnInterchangeAssembly);
}
[Fact]
public void TypeNameCollisionDetection_ActuallyDetectsACollision()
{
var collisions = FindTypeNameCollisions(typeof(BpmnLibraryDuplicationGuardTests).Assembly, BpmnSemanticsAssembly);
Assert.NotEmpty(collisions);
Assert.Contains(collisions, message => message.Contains(nameof(BpmnGraph)));
}
private static void AssertNoTypeNameCollisions(Assembly elsaAssembly, params Assembly[] libraryAssemblies)
{
var collisions = FindTypeNameCollisions(elsaAssembly, libraryAssemblies);
Assert.True(
collisions.Count == 0,
$"{elsaAssembly.GetName().Name} must not reimplement BPMN semantics the library already provides:{Environment.NewLine}{string.Join(Environment.NewLine, collisions)}");
}
private static IReadOnlyList<string> FindTypeNameCollisions(Assembly elsaAssembly, params Assembly[] libraryAssemblies)
{
var libraryTypesByName = libraryAssemblies
.SelectMany(a => a.GetTypes())
.Where(t => !IsCompilerGenerated(t))
.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First());
return elsaAssembly
.GetTypes()
.Where(t => !IsCompilerGenerated(t))
.Where(t => libraryTypesByName.ContainsKey(t.Name))
.Select(t => $"{t.FullName} (in {elsaAssembly.GetName().Name}) duplicates {libraryTypesByName[t.Name].FullName} (in {libraryTypesByName[t.Name].Assembly.GetName().Name})")
.OrderBy(message => message)
.ToList();
}
// The declaring-type walk is load-bearing, not defensive. A collection expression targeting an
// IReadOnlyList<T> makes the compiler synthesize a <>z__ReadOnlySingleElementList<T> into the
// assembly; that outer type is filtered by its name, but its nested Enumerator struct is named
// plainly and carries no CompilerGeneratedAttribute of its own, so it reads as a hand-written type
// colliding with the identically synthesized one in Bpmn.Model. That is a false positive: nothing a
// person wrote is ever nested inside a <>-named type, and this guard's own fixture (BpmnGraph,
// nested in an ordinary class) still trips the detector. Without this, the guard fails the moment
// anyone writes a single-element collection expression in Elsa.Bpmn*, which teaches people to work
// around it rather than to trust it.
private static bool IsCompilerGenerated(Type type) =>
type.Name.StartsWith('<') ||
type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) ||
(type.DeclaringType is { } declaringType && IsCompilerGenerated(declaringType));
/// <summary>
/// Exists solely as this guard's own fixture: its name deliberately collides with
/// Bpmn.Semantics' BpmnGraph so <see cref="TypeNameCollisionDetection_ActuallyDetectsACollision"/>
/// can prove the detector fires. Must not be renamed.
/// </summary>
private sealed class BpmnGraph;
}