elsa-core/test/unit/Elsa.Bpmn.Interchange.UnitTests/BpmnLibraryDuplicationGuardTests.cs

118 lines
6.7 KiB
C#
Raw Normal View History

test(bpmn): guard that Elsa.Bpmn never duplicates the library (#7939) * test(bpmn): guard against Elsa.Bpmn* reimplementing Bpmn.* library types Adds a reflection-based architecture test asserting no type under Elsa.Bpmn or Elsa.Bpmn.Interchange shares a type name with Bpmn.Model/Bpmn.Semantics (and Bpmn.Interchange for the interchange assembly), plus a positive check that both assemblies still depend on their respective library packages - closing the gap that let elsa-foundation grow a parallel BpmnElement/BpmnGraph semantics core undetected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): move the library-duplication guard to the interchange test project Elsa.Bpmn.UnitTests referencing Elsa.Bpmn.Interchange (plus three redundant PackageReferences already flowing transitively) inverted the layering the guard exists to protect. Elsa.Bpmn.Interchange.UnitTests already sees both assemblies transitively with no new references, so the guard moves there unchanged apart from namespace and doc. Also drops the silent null-forgive on Assembly.GetName().Name in favor of an explained fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): drop the dependency-direction assertions the compiler already enforces Mutation testing showed AssertDependsOnPackage and the two positive facts using it can never go red: any state that would trip them fails the test project's build first (CS0234), because the guard's own typeof bindings already require the Bpmn.* packages. Delete the decorative facts and the now-unused helper, and document that the typeof bindings are load-bearing on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): prove the duplication detector actually detects Split AssertNoTypeNameCollisions into a thin assertion wrapper around a new FindTypeNameCollisions helper, and add a fact that points the detector at the test assembly (which carries a deliberately colliding BpmnGraph fixture) so CI sees the guard fail as well as pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 02:09:53 +00:00
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();
}
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 15:38:03 +00:00
// 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.
test(bpmn): guard that Elsa.Bpmn never duplicates the library (#7939) * test(bpmn): guard against Elsa.Bpmn* reimplementing Bpmn.* library types Adds a reflection-based architecture test asserting no type under Elsa.Bpmn or Elsa.Bpmn.Interchange shares a type name with Bpmn.Model/Bpmn.Semantics (and Bpmn.Interchange for the interchange assembly), plus a positive check that both assemblies still depend on their respective library packages - closing the gap that let elsa-foundation grow a parallel BpmnElement/BpmnGraph semantics core undetected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): move the library-duplication guard to the interchange test project Elsa.Bpmn.UnitTests referencing Elsa.Bpmn.Interchange (plus three redundant PackageReferences already flowing transitively) inverted the layering the guard exists to protect. Elsa.Bpmn.Interchange.UnitTests already sees both assemblies transitively with no new references, so the guard moves there unchanged apart from namespace and doc. Also drops the silent null-forgive on Assembly.GetName().Name in favor of an explained fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): drop the dependency-direction assertions the compiler already enforces Mutation testing showed AssertDependsOnPackage and the two positive facts using it can never go red: any state that would trip them fails the test project's build first (CS0234), because the guard's own typeof bindings already require the Bpmn.* packages. Delete the decorative facts and the now-unused helper, and document that the typeof bindings are load-bearing on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): prove the duplication detector actually detects Split AssertNoTypeNameCollisions into a thin assertion wrapper around a new FindTypeNameCollisions helper, and add a fact that points the detector at the test assembly (which carries a deliberately colliding BpmnGraph fixture) so CI sees the guard fail as well as pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 02:09:53 +00:00
private static bool IsCompilerGenerated(Type type) =>
type.Name.StartsWith('<') ||
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 15:38:03 +00:00
type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) ||
(type.DeclaringType is { } declaringType && IsCompilerGenerated(declaringType));
test(bpmn): guard that Elsa.Bpmn never duplicates the library (#7939) * test(bpmn): guard against Elsa.Bpmn* reimplementing Bpmn.* library types Adds a reflection-based architecture test asserting no type under Elsa.Bpmn or Elsa.Bpmn.Interchange shares a type name with Bpmn.Model/Bpmn.Semantics (and Bpmn.Interchange for the interchange assembly), plus a positive check that both assemblies still depend on their respective library packages - closing the gap that let elsa-foundation grow a parallel BpmnElement/BpmnGraph semantics core undetected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): move the library-duplication guard to the interchange test project Elsa.Bpmn.UnitTests referencing Elsa.Bpmn.Interchange (plus three redundant PackageReferences already flowing transitively) inverted the layering the guard exists to protect. Elsa.Bpmn.Interchange.UnitTests already sees both assemblies transitively with no new references, so the guard moves there unchanged apart from namespace and doc. Also drops the silent null-forgive on Assembly.GetName().Name in favor of an explained fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): drop the dependency-direction assertions the compiler already enforces Mutation testing showed AssertDependsOnPackage and the two positive facts using it can never go red: any state that would trip them fails the test project's build first (CS0234), because the guard's own typeof bindings already require the Bpmn.* packages. Delete the decorative facts and the now-unused helper, and document that the typeof bindings are load-bearing on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(bpmn): prove the duplication detector actually detects Split AssertNoTypeNameCollisions into a thin assertion wrapper around a new FindTypeNameCollisions helper, and add a fact that points the detector at the test assembly (which carries a deliberately colliding BpmnGraph fixture) so CI sees the guard fail as well as pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 02:09:53 +00:00
/// <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;
}